diff --git a/.editorconfig b/.editorconfig index 1cc80b05..f42c94e8 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,6 +4,7 @@ root = true dotnet_sort_system_directives_first = false # XML documentation +dotnet_diagnostic.CS1574.severity = warning dotnet_diagnostic.CS1591.severity = warning # ASP.NET @@ -11,13 +12,16 @@ dotnet_diagnostic.ASP0015.severity = warning # Code Analysis (CA) dotnet_diagnostic.CA1050.severity = warning +dotnet_diagnostic.CA1416.severity = warning dotnet_diagnostic.CA1401.severity = warning dotnet_diagnostic.CA1806.severity = warning dotnet_diagnostic.CA1816.severity = warning dotnet_diagnostic.CA1822.severity = warning dotnet_diagnostic.CA1826.severity = warning +dotnet_diagnostic.CA1834.severity = warning dotnet_diagnostic.CA1847.severity = warning dotnet_diagnostic.CA1859.severity = warning +dotnet_diagnostic.CA1861.severity = warning dotnet_diagnostic.CA1869.severity = warning dotnet_diagnostic.CA2016.severity = warning @@ -30,11 +34,14 @@ dotnet_diagnostic.IDE0059.severity = warning dotnet_diagnostic.IDE0060.severity = warning dotnet_diagnostic.IDE0074.severity = warning dotnet_diagnostic.IDE0180.severity = warning +dotnet_diagnostic.IDE0230.severity = warning dotnet_diagnostic.IDE0270.severity = warning dotnet_diagnostic.IDE0290.severity = warning +dotnet_diagnostic.IDE0300.severity = warning dotnet_diagnostic.IDE0301.severity = warning dotnet_diagnostic.IDE0305.severity = warning dotnet_diagnostic.IDE0330.severity = warning +dotnet_diagnostic.IDE1006.severity = warning # System library dotnet_diagnostic.SYSLIB1054.severity = warning diff --git a/.gitattributes b/.gitattributes index b9e03f9d..c185d0b0 100644 --- a/.gitattributes +++ b/.gitattributes @@ -4,3 +4,4 @@ *.bats text eol=lf *.bash text eol=lf scripts/*.cs text eol=lf +scripts/**/*.cs text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d70a4a3e..216b1807 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,14 +32,51 @@ jobs: run: dotnet build --no-restore - name: Test - run: dotnet test --no-build --verbosity normal --collect:"XPlat Code Coverage" + timeout-minutes: 30 + shell: bash + run: | + set -euo pipefail - - name: Upload coverage + for project in \ + tests/Dotsider.Tests/Dotsider.Tests.csproj \ + tests/Dotsider.Mcp.Tests/Dotsider.Mcp.Tests.csproj \ + tests/Dotsider.Website.Tests/Dotsider.Website.Tests.csproj + do + project_name="$(basename "$(dirname "$project")")" + result_dir="TestResults/${{ matrix.os }}/${project_name}" + mkdir -p "$result_dir" + export DOTSIDER_R2R_DIAGNOSTICS=1 + export DOTSIDER_R2R_DIAGNOSTICS_PATH="$result_dir/r2r-diagnostics.log" + : > "$DOTSIDER_R2R_DIAGNOSTICS_PATH" + set +e + dotnet test "$project" \ + --no-build \ + --report-trx \ + --results-directory "$result_dir" \ + --coverage \ + --coverage-output-format cobertura \ + --diagnostic \ + --diagnostic-verbosity Trace \ + --diagnostic-output-directory "$result_dir" \ + --diagnostic-file-prefix "$project_name" \ + 2>&1 | tee "$result_dir/dotnet-test.log" + status=${PIPESTATUS[0]} + set -e + if [ "$status" -ne 0 ]; then + exit "$status" + fi + done + + - name: Upload test artifacts if: always() uses: actions/upload-artifact@v7 with: - name: coverage-${{ matrix.os }} - path: '**/TestResults/**/coverage.cobertura.xml' + name: test-results-${{ matrix.os }} + path: | + TestResults/**/*.trx + TestResults/**/*.cobertura.xml + TestResults/**/*.diag + TestResults/**/*.log deploy-tests: runs-on: ubuntu-latest diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..c4459fac --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,7 @@ + + + latest + true + true + + diff --git a/Dotsider.slnx b/Dotsider.slnx index 59a760ff..2ebf7a6e 100644 --- a/Dotsider.slnx +++ b/Dotsider.slnx @@ -1,4 +1,15 @@ + + + + + + + + + + + @@ -40,12 +51,26 @@ + + + + + + + + + + + + + + diff --git a/benchmarks/Dotsider.Benchmarks/Dotsider.Benchmarks.csproj b/benchmarks/Dotsider.Benchmarks/Dotsider.Benchmarks.csproj index 23c4cc51..1c825d49 100644 --- a/benchmarks/Dotsider.Benchmarks/Dotsider.Benchmarks.csproj +++ b/benchmarks/Dotsider.Benchmarks/Dotsider.Benchmarks.csproj @@ -12,7 +12,7 @@ - + diff --git a/global.json b/global.json new file mode 100644 index 00000000..7c02fe68 --- /dev/null +++ b/global.json @@ -0,0 +1,8 @@ +{ + "test": { + "runner": "Microsoft.Testing.Platform" + }, + "msbuild-sdks": { + "MSTest.Sdk": "4.3.0" + } +} diff --git a/samples/AppLocalRollForward/AppLocalRollForward.csproj b/samples/AppLocalRollForward/AppLocalRollForward.csproj index 1927ce7b..a5743a1b 100644 --- a/samples/AppLocalRollForward/AppLocalRollForward.csproj +++ b/samples/AppLocalRollForward/AppLocalRollForward.csproj @@ -24,7 +24,7 @@ build of the same package, so the deployed app-local DLL has the same simple name and PKT but a strictly greater assembly version. That divergence is the scenario the resolver fix exists to handle. --> - + diff --git a/samples/NativeAotArtifactsConsole/Directory.Build.props b/samples/NativeAotArtifactsConsole/Directory.Build.props index b38e4240..365f9f68 100644 --- a/samples/NativeAotArtifactsConsole/Directory.Build.props +++ b/samples/NativeAotArtifactsConsole/Directory.Build.props @@ -1,7 +1,9 @@ + project file). Scoped to this sample. --> + + true diff --git a/samples/NetFxBindingRedirects.Clr2/Program.cs b/samples/NetFxBindingRedirects.Clr2/Program.cs index 52e61c7b..d72c0210 100644 --- a/samples/NetFxBindingRedirects.Clr2/Program.cs +++ b/samples/NetFxBindingRedirects.Clr2/Program.cs @@ -167,7 +167,7 @@ private static string ToJson(IDictionary map) sb.Append(" \"fullName\": ").Append(JsonString(kv.Value.FullName)).Append(",\n"); sb.Append(" \"location\": ").Append(JsonString(kv.Value.Location)).Append(",\n"); sb.Append(" \"loaded\": ").Append(kv.Value.Loaded ? "true" : "false").Append(",\n"); - sb.Append(" \"error\": ").Append(JsonString(kv.Value.Error)).Append("\n"); + sb.Append(" \"error\": ").Append(JsonString(kv.Value.Error)).Append('\n'); sb.Append(" }"); } sb.Append("\n}\n"); diff --git a/samples/NetFxBindingRedirects.NewDep/NetFxBindingRedirects.NewDep.csproj b/samples/NetFxBindingRedirects.NewDep/NetFxBindingRedirects.NewDep.csproj index bc5bec17..0360f266 100644 --- a/samples/NetFxBindingRedirects.NewDep/NetFxBindingRedirects.NewDep.csproj +++ b/samples/NetFxBindingRedirects.NewDep/NetFxBindingRedirects.NewDep.csproj @@ -12,7 +12,7 @@ - - + + diff --git a/samples/NetFxBindingRedirects.OldDep/NetFxBindingRedirects.OldDep.csproj b/samples/NetFxBindingRedirects.OldDep/NetFxBindingRedirects.OldDep.csproj index 334b4544..de6b9fe2 100644 --- a/samples/NetFxBindingRedirects.OldDep/NetFxBindingRedirects.OldDep.csproj +++ b/samples/NetFxBindingRedirects.OldDep/NetFxBindingRedirects.OldDep.csproj @@ -12,7 +12,7 @@ - + diff --git a/samples/NetFxBindingRedirects/NetFxBindingRedirects.csproj b/samples/NetFxBindingRedirects/NetFxBindingRedirects.csproj index 4dc3c4cd..b12c7ff1 100644 --- a/samples/NetFxBindingRedirects/NetFxBindingRedirects.csproj +++ b/samples/NetFxBindingRedirects/NetFxBindingRedirects.csproj @@ -1,7 +1,7 @@ + @@ -44,27 +45,17 @@ - - - + + + - - - - + + + + diff --git a/samples/NetFxBindingRedirects/Program.cs b/samples/NetFxBindingRedirects/Program.cs index cfe5e96c..e809d7db 100644 --- a/samples/NetFxBindingRedirects/Program.cs +++ b/samples/NetFxBindingRedirects/Program.cs @@ -132,7 +132,7 @@ private static string ToJson(IDictionary map) sb.Append(" \"fullName\": ").Append(JsonString(kv.Value.FullName)).Append(",\n"); sb.Append(" \"location\": ").Append(JsonString(kv.Value.Location)).Append(",\n"); sb.Append(" \"loaded\": ").Append(kv.Value.Loaded ? "true" : "false").Append(",\n"); - sb.Append(" \"error\": ").Append(JsonString(kv.Value.Error)).Append("\n"); + sb.Append(" \"error\": ").Append(JsonString(kv.Value.Error)).Append('\n'); sb.Append(" }"); } sb.Append("\n}\n"); diff --git a/scripts/Directory.Build.props b/scripts/Directory.Build.props index dfa77527..f7382f00 100644 --- a/scripts/Directory.Build.props +++ b/scripts/Directory.Build.props @@ -1,14 +1,16 @@ - - latest - enable - enable - true - latest - true - true - true - false - false - + + + + latest + enable + enable + true + latest + true + true + true + false + false + diff --git a/scripts/README.md b/scripts/README.md index e6020f5d..9db9a9f7 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,125 +1,30 @@ # Scripts dotsider repository utilities are .NET file-based apps. They require the .NET 10 -SDK. - -The implementation follows the Microsoft file-based app guidance: +SDK and follow the Microsoft file-based app guidance: https://learn.microsoft.com/en-us/dotnet/core/sdk/file-based-apps -Run a utility with: +Run a utility with `dotnet run --file`: ```powershell +dotnet run --file ./scripts/Run-Tests.cs dotnet run --file ./scripts/Capture-DisasmOracle.cs -- -Architecture riscv64 -Fixture path/to/blob.bin -OraclePath llvm-objdump -OutputDirectory artifacts/oracles/disasm -- -D -b binary -m riscv:rv64 path/to/blob.bin ``` -Large disassembly tools can emit very large streams. Use -`-MaxOutputCharacters` to cap retained stdout/stderr while still draining the -process to completion, `-TimeoutSeconds` to kill long-running oracle tools, and -`-AllowOracleFailure` when the capture should upload diagnostics from a failing -oracle tool instead of failing before artifacts are saved. - -For automation or repeated invocations, build first and run without rebuilding: - -```powershell -dotnet build ./scripts/Capture-DisasmOracle.cs --nologo --verbosity quiet -dotnet run --file ./scripts/Capture-DisasmOracle.cs --no-build -- -Architecture riscv64 -Fixture path/to/blob.bin -OraclePath llvm-objdump -OutputDirectory artifacts/oracles/disasm -- -D -b binary -m riscv:rv64 path/to/blob.bin -``` - -The build-first pattern avoids file-based app cache contention when multiple -processes might run the same utility at the same time. - -If the SDK cache gets stale or contested, clear all file-based app cache output: - -```powershell -dotnet clean file-based-apps -``` - -To force a clean build for one utility: - -```powershell -dotnet clean ./scripts/Capture-DisasmOracle.cs -dotnet build ./scripts/Capture-DisasmOracle.cs -``` - -The executable utilities have a Unix shebang: - -```text -#!/usr/bin/env -S dotnet -- -``` - -The repository uses LF line endings through `.gitattributes`, which is required -for direct Unix execution. On Unix-like systems, executable files can also be run -directly after checkout when the executable bit is present: - -```bash -./scripts/Capture-DisasmOracle.cs -Architecture riscv64 -Fixture path/to/blob.bin -OraclePath llvm-objdump -OutputDirectory artifacts/oracles/disasm -- -D -b binary -m riscv:rv64 path/to/blob.bin -``` - Keep file-based apps under `scripts/`, outside project directories. The local `scripts/Directory.Build.props` intentionally isolates utility app settings from -package metadata and project settings used by the shipped dotsider projects. - -Repository tests build every executable utility app with `dotnet build` and run -stable fake-input coverage for the disassembly oracle capture app. When adding a -utility, keep its top-level launcher thin and put behavior in a documented app -class or documented helper methods so XML documentation and convention tests -cover hoverable code. +package metadata and project settings used by shipped dotsider projects. -dotsider decoder tests prefer real sample assemblies over hand-written byte -fixtures. The shared test fixture publishes cross-RID ReadyToRun samples for -architecture-specific coverage when the SDK provides the RID packs. Use -`Capture-DisasmOracle.cs` for architectures that require runtime-built inputs or -external oracle captures, then review and promote the normalized artifacts into -committed fixtures. - -Normal `dotnet publish` cannot always produce every decoder target on every -machine. Current public SDK feeds may not include runtime/host packs for -`linux-riscv64` or `linux-loongarch64`; those ReadyToRun fixture paths are null -when restore cannot resolve the packs. Browser and WASI Wasm do not support -ReadyToRun publishing; the browser-wasm fixture publishes with -`PublishReadyToRun=false` and decodes the real SDK-produced -`dotnet.native.wasm` module instead. If a runtime-built input or an external -oracle is needed, capture it with the file-based utility and commit the reviewed -fixture metadata, including the runtime source files used as ground truth. -The .NET runtime repo validates RISC-V64 and LoongArch64 through its -cross-target runtime pipeline and SuperPMI/crossgen2 collections, so dotsider CI -does not try to build private runtime packs as part of normal test execution. - -The `Native architecture oracles` workflow is the outer-loop capture pipeline. -It can be run manually from GitHub Actions and also refreshes SDK-backed oracle -captures on a weekly schedule. The default path installs `wasm-tools`, publishes -the browser-wasm fixture, opportunistically tries SDK ReadyToRun publishes for -`linux-riscv64` and `linux-loongarch64`, captures any available oracle output -with `Capture-DisasmOracle.cs`, and uploads the unreviewed artifacts. - -When public SDK packs are unavailable, run the workflow manually with -`run-runtime-cross-target=true`. That opt-in path checks out `dotnet/runtime` at -the requested ref and uses the same pinned Azure Linux cross-build containers -and rootfs paths that runtime uses for RISC-V64 and LoongArch64. The resulting -runtime logs and artifact locations are uploaded for review; promoted fixtures -should still flow through `Capture-DisasmOracle.cs` so every committed oracle -records the producer ref, fixture hash, and command line. - -For local development, the same shape applies: - -```powershell -$env:DOTSIDER_RUNTIME_ROOT = "D:\SRC\runtime" -dotnet build ./scripts/Capture-DisasmOracle.cs --nologo --verbosity quiet -dotnet run --file ./scripts/Capture-DisasmOracle.cs --no-build -- ` - -Architecture loongarch64 ` - -Fixture artifacts/oracles/input/loongarch64-smoke.bin ` - -OraclePath llvm-objdump ` - -RuntimeRoot $env:DOTSIDER_RUNTIME_ROOT ` - -OutputDirectory artifacts/oracles/disasm ` - -- -d artifacts/oracles/input/loongarch64-smoke.bin -``` +The executable utilities start with `#!/usr/bin/env -S dotnet --`, and the +repository enforces LF line endings for script files so direct Unix execution +works when the executable bit is present. Current utilities: | App | Purpose | | --- | --- | | `Capture-DisasmOracle.cs` | Capture external native-disassembly oracle output and metadata. | +| `Run-Tests.cs` | Run `dotnet test` once or repeatedly with forwarded test arguments. | -Oracle captures stay under ignored `artifacts/` paths until they have been -reviewed and normalized into committed test fixtures. +Use each script's XML documentation and command-line help for option details. diff --git a/scripts/Run-Tests.cs b/scripts/Run-Tests.cs new file mode 100644 index 00000000..fa3696a8 --- /dev/null +++ b/scripts/Run-Tests.cs @@ -0,0 +1,397 @@ +#!/usr/bin/env -S dotnet -- +#:property TargetFramework=net10.0 +#:property PackAsTool=false +#:package System.CommandLine@2.0.9 +#:include ScriptSupport.cs + +using System.CommandLine; +using System.Diagnostics; + +try +{ + return TestRunApp.Run(args); +} +catch (Exception ex) when (ex is ArgumentException or DirectoryNotFoundException or FileNotFoundException or InvalidOperationException) +{ + Console.Error.WriteLine(ex.Message); + return 1; +} + +/// +/// Runs the repository test suite through dotnet test. +/// The app can repeat the same command to expose parallelization flakes. +/// Extra arguments are forwarded unchanged after the script argument separator. +/// System.CommandLine provides option parsing and help output. +/// Child dotnet test processes disable MSBuild node reuse so repeat runs +/// do not leave idle build worker processes behind on developer machines. +/// +/// +/// With no arguments, dotnet run --file ./scripts/Run-Tests.cs runs +/// dotnet test once for the full solution. Use -Count to repeat +/// the same command, -Target to select a project or solution, and +/// -- to forward native dotnet test arguments such as +/// --filter "FullyQualifiedName~SomeTest". +/// +internal static class TestRunApp +{ + private const string DisableMsBuildNodeReuseVariable = "MSBUILDDISABLENODEREUSE"; + + /// + /// Parses script arguments and executes one or more dotnet test attempts. + /// The first failing exit code is returned after all attempts finish. + /// Use -StopOnFailure when an early failing attempt is enough evidence. + /// + /// The command-line arguments. + /// The process exit code. + internal static int Run(string[] args) + { + Option countOption = new("--count", "-Count") + { + HelpName = "N", + Description = "Number of test attempts. Defaults to 1.", + }; + Option repeatOption = new("--repeat", "-Repeat") + { + HelpName = "N", + Description = "Alias for -Count.", + }; + Option targetOption = new("--target", "-Target") + { + HelpName = "PATH", + Description = "Optional solution, project, or directory passed to dotnet test.", + }; + Option workingDirectoryOption = new("--working-directory", "-WorkingDirectory") + { + HelpName = "DIR", + Description = "Directory for dotnet test. Defaults to the repository root.", + }; + Option timeoutSecondsOption = new("--timeout-seconds", "-TimeoutSeconds") + { + HelpName = "SECONDS", + Description = "Per-attempt process timeout in seconds.", + }; + Option logDirectoryOption = new("--log-directory", "-LogDirectory") + { + HelpName = "DIR", + Description = "Write one combined stdout/stderr log per attempt.", + }; + Option maxOutputCharactersOption = new("--max-output-characters", "-MaxOutputCharacters") + { + HelpName = "CHARS", + Description = "Maximum stdout/stderr characters retained per stream when logging.", + DefaultValueFactory = _ => 4_000_000, + }; + Option stopOnFailureOption = new("--stop-on-failure", "-StopOnFailure") + { + Description = "Stop after the first failing attempt.", + }; + Argument dotnetTestArgument = new("dotnet-test-arguments") + { + HelpName = "ARGS", + Description = "Arguments forwarded to dotnet test after --.", + Arity = ArgumentArity.ZeroOrMore, + }; + RootCommand rootCommand = new("Runs dotnet test once or repeatedly.") + { + countOption, + repeatOption, + targetOption, + workingDirectoryOption, + timeoutSecondsOption, + logDirectoryOption, + maxOutputCharactersOption, + stopOnFailureOption, + dotnetTestArgument, + }; + rootCommand.SetAction(parseResult => + { + string repositoryRoot = ScriptSupport.FindRepositoryRoot(); + string workingDirectory = ResolveWorkingDirectory(parseResult.GetValue(workingDirectoryOption), repositoryRoot); + string target = ResolveTarget(parseResult.GetValue(targetOption), workingDirectory); + int count = ResolveCount(parseResult.GetValue(countOption), parseResult.GetValue(repeatOption)); + TimeSpan? timeout = ResolveTimeout(parseResult.GetValue(timeoutSecondsOption)); + string logDirectory = ResolveLogDirectory(parseResult.GetValue(logDirectoryOption), workingDirectory); + int maxOutputCharacters = ValidatePositive(parseResult.GetValue(maxOutputCharactersOption), "MaxOutputCharacters"); + bool stopOnFailure = parseResult.GetValue(stopOnFailureOption); + string[] dotnetTestArguments = parseResult.GetValue(dotnetTestArgument) ?? []; + + return RunAttempts( + workingDirectory, + target, + count, + timeout, + logDirectory, + maxOutputCharacters, + stopOnFailure, + dotnetTestArguments); + }); + + return rootCommand.Parse(NormalizeLegacyHelpAliases(args)).Invoke(); + } + + private static int RunAttempts( + string workingDirectory, + string target, + int count, + TimeSpan? timeout, + string logDirectory, + int maxOutputCharacters, + bool stopOnFailure, + string[] dotnetTestArguments) + { + var commandArguments = new List { "test" }; + if (!string.IsNullOrWhiteSpace(target)) + { + commandArguments.Add(target); + } + + commandArguments.AddRange(dotnetTestArguments); + + var failureCount = 0; + var firstFailureExitCode = 0; + var attemptsRun = 0; + var total = Stopwatch.StartNew(); + + for (var attempt = 1; attempt <= count; attempt++) + { + attemptsRun++; + Console.WriteLine(); + Console.WriteLine($"dotnet test attempt {attempt}/{count}"); + Console.WriteLine($"dotnet {FormatCommand(commandArguments)}"); + + TestAttemptResult result = string.IsNullOrWhiteSpace(logDirectory) + ? RunDotnetTestStreaming(commandArguments, workingDirectory, timeout) + : RunDotnetTestCaptured(commandArguments, workingDirectory, timeout, logDirectory, maxOutputCharacters, attempt); + + string outcome = result.ExitCode == 0 ? "passed" : result.TimedOut ? "timed out" : "failed"; + Console.WriteLine($"attempt {attempt}/{count} {outcome} in {result.Elapsed:c} (exit code {result.ExitCode})"); + if (!string.IsNullOrWhiteSpace(result.LogPath)) + { + Console.WriteLine(result.LogPath); + } + + if (result.ExitCode != 0) + { + failureCount++; + firstFailureExitCode = firstFailureExitCode == 0 ? result.ExitCode : firstFailureExitCode; + if (stopOnFailure) + { + break; + } + } + } + + Console.WriteLine(); + Console.WriteLine($"test attempts completed: {attemptsRun} requested={count} failed={failureCount} elapsed={total.Elapsed:c}"); + return failureCount == 0 ? 0 : firstFailureExitCode; + } + + private static TestAttemptResult RunDotnetTestStreaming( + List commandArguments, + string workingDirectory, + TimeSpan? timeout) + { + var startInfo = new ProcessStartInfo("dotnet") + { + UseShellExecute = false, + WorkingDirectory = workingDirectory, + }; + ConfigureDotnetTestEnvironment(startInfo.Environment); + foreach (string argument in commandArguments) + { + startInfo.ArgumentList.Add(argument); + } + + var stopwatch = Stopwatch.StartNew(); + using Process process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start dotnet."); + bool timedOut = WaitForExit(process, timeout); + return new TestAttemptResult(timedOut ? 124 : process.ExitCode, stopwatch.Elapsed, timedOut, ""); + } + + private static TestAttemptResult RunDotnetTestCaptured( + List commandArguments, + string workingDirectory, + TimeSpan? timeout, + string logDirectory, + int maxOutputCharacters, + int attempt) + { + var stopwatch = Stopwatch.StartNew(); + (int exitCode, string stdout, string stderr, bool stdoutTruncated, bool stderrTruncated, bool timedOut) = ScriptSupport.RunProcess( + "dotnet", + commandArguments, + workingDirectory, + maxOutputCharacters, + timeout, + CreateDotnetTestEnvironment()); + + string logPath = Path.Combine(logDirectory, $"dotnet-test-{attempt:0000}.log"); + ScriptSupport.WriteTextFile( + logPath, + string.Join( + Environment.NewLine, + $"WorkingDirectory: {workingDirectory}", + $"Command: dotnet {FormatCommand(commandArguments)}", + $"ExitCode: {(timedOut ? 124 : exitCode)}", + $"TimedOut: {timedOut}", + $"StdoutTruncated: {stdoutTruncated}", + $"StderrTruncated: {stderrTruncated}", + "", + "stdout:", + stdout, + "", + "stderr:", + stderr)); + + Console.Write(stdout); + if (!string.IsNullOrWhiteSpace(stderr)) + { + Console.Error.Write(stderr); + } + + return new TestAttemptResult(timedOut ? 124 : exitCode, stopwatch.Elapsed, timedOut, logPath); + } + + private static Dictionary CreateDotnetTestEnvironment() => + new(StringComparer.OrdinalIgnoreCase) + { + [DisableMsBuildNodeReuseVariable] = "1", + }; + + private static void ConfigureDotnetTestEnvironment(IDictionary environment) + { + environment[DisableMsBuildNodeReuseVariable] = "1"; + } + + private static bool WaitForExit(Process process, TimeSpan? timeout) + { + if (timeout is null) + { + process.WaitForExit(); + return false; + } + + int milliseconds = checked((int)Math.Min(timeout.Value.TotalMilliseconds, int.MaxValue)); + if (milliseconds <= 0) + { + throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be positive."); + } + + if (process.WaitForExit(milliseconds)) + { + return false; + } + + try + { + process.Kill(entireProcessTree: true); + } + catch (InvalidOperationException) + { + return false; + } + + process.WaitForExit(); + return true; + } + + private static string ResolveWorkingDirectory(string? value, string repositoryRoot) + { + if (string.IsNullOrWhiteSpace(value)) + { + return repositoryRoot; + } + + return ScriptSupport.ResolveWorkingDirectory(value); + } + + private static string ResolveTarget(string? value, string workingDirectory) + { + if (string.IsNullOrWhiteSpace(value)) + { + return ""; + } + + string candidate = Path.IsPathFullyQualified(value) + ? value + : Path.Combine(workingDirectory, value); + if (File.Exists(candidate) || Directory.Exists(candidate)) + { + return Path.GetFullPath(candidate); + } + + throw new FileNotFoundException($"Target '{value}' does not exist."); + } + + private static int ResolveCount(int? count, int? repeat) => + ValidatePositive(count ?? repeat ?? 1, "Count"); + + private static TimeSpan? ResolveTimeout(int? timeoutSeconds) => + timeoutSeconds is null + ? null + : TimeSpan.FromSeconds(ValidatePositive(timeoutSeconds.Value, "TimeoutSeconds")); + + private static string ResolveLogDirectory(string? value, string workingDirectory) + { + if (string.IsNullOrWhiteSpace(value)) + { + return ""; + } + + string resolved = Path.IsPathFullyQualified(value) + ? value + : Path.Combine(workingDirectory, value); + Directory.CreateDirectory(resolved); + return Path.GetFullPath(resolved); + } + + private static int ValidatePositive(int value, string optionName) + { + if (value <= 0) + { + throw new ArgumentException($"-{optionName} must be a positive integer."); + } + + return value; + } + + private static string[] NormalizeLegacyHelpAliases(string[] args) + { + string[] normalized = [.. args]; + for (var i = 0; i < normalized.Length; i++) + { + if (normalized[i].Equals("--", StringComparison.Ordinal)) + { + break; + } + + if (IsLegacyHelpAlias(normalized[i])) + { + normalized[i] = "--help"; + } + } + + return normalized; + } + + private static bool IsLegacyHelpAlias(string token) => + token.Equals("-help", StringComparison.OrdinalIgnoreCase) + || token.Equals("--help", StringComparison.OrdinalIgnoreCase) + || token.Equals("-?", StringComparison.OrdinalIgnoreCase) + || token.Equals("/?", StringComparison.OrdinalIgnoreCase); + + private static string FormatCommand(IEnumerable arguments) => + string.Join(' ', arguments.Select(QuoteArgument)); + + private static string QuoteArgument(string argument) + { + if (argument.Length == 0) + { + return "\"\""; + } + + return argument.Any(char.IsWhiteSpace) ? $"\"{argument.Replace("\"", "\\\"", StringComparison.Ordinal)}\"" : argument; + } + + private readonly record struct TestAttemptResult(int ExitCode, TimeSpan Elapsed, bool TimedOut, string LogPath); +} diff --git a/scripts/ScriptSupport.cs b/scripts/ScriptSupport.cs index 16955660..a0f41ac3 100644 --- a/scripts/ScriptSupport.cs +++ b/scripts/ScriptSupport.cs @@ -306,13 +306,15 @@ internal static string ResolveCommandPath(string commandPath, string description /// The process working directory. /// The maximum characters to retain per stream. /// The optional process timeout. + /// Optional environment variable overrides for the process. /// The exit code, stdout, stderr, truncation state, and timeout state. internal static (int ExitCode, string Stdout, string Stderr, bool StdoutTruncated, bool StderrTruncated, bool TimedOut) RunProcess( string filePath, IEnumerable arguments, string workingDirectory, int maxOutputCharacters = int.MaxValue, - TimeSpan? timeout = null) + TimeSpan? timeout = null, + IReadOnlyDictionary? environment = null) { var startInfo = new ProcessStartInfo(filePath) { @@ -327,6 +329,20 @@ internal static (int ExitCode, string Stdout, string Stderr, bool StdoutTruncate startInfo.ArgumentList.Add(argument); } + if (environment is not null) + { + foreach ((string key, string? value) in environment) + { + if (value is null) + { + startInfo.Environment.Remove(key); + continue; + } + + startInfo.Environment[key] = value; + } + } + using Process process = Process.Start(startInfo) ?? throw new InvalidOperationException($"Failed to start '{filePath}'."); Task<(string Text, bool Truncated)> stdoutTask = Task.Run(() => ReadBoundedToEnd(process.StandardOutput, maxOutputCharacters)); Task<(string Text, bool Truncated)> stderrTask = Task.Run(() => ReadBoundedToEnd(process.StandardError, maxOutputCharacters)); diff --git a/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunDiagnostics.cs b/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunDiagnostics.cs new file mode 100644 index 00000000..37939280 --- /dev/null +++ b/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunDiagnostics.cs @@ -0,0 +1,93 @@ +using System.Diagnostics; + +namespace Dotsider.Core.Analysis.ReadyToRun; + +internal static class ReadyToRunDiagnostics +{ + private const string EnabledVariable = "DOTSIDER_R2R_DIAGNOSTICS"; + private const string PathVariable = "DOTSIDER_R2R_DIAGNOSTICS_PATH"; + private const string MaxLinesVariable = "DOTSIDER_R2R_DIAGNOSTICS_MAX_LINES"; + private const int DefaultMaxLines = 50_000; + + private static readonly Lock Gate = new(); + private static readonly bool Enabled = IsEnabled(Environment.GetEnvironmentVariable(EnabledVariable)); + private static readonly string? LogPath = Enabled ? NormalizePath(Environment.GetEnvironmentVariable(PathVariable)) : null; + private static readonly int MaxLines = ResolveMaxLines(Environment.GetEnvironmentVariable(MaxLinesVariable)); + private static readonly Lazy Writer = new(CreateWriter); + private static int s_linesWritten; + private static int s_truncated; + + public static void Write(string message) + { + if (!Enabled) + return; + + if (MaxLines > 0) + { + var lineNumber = Interlocked.Increment(ref s_linesWritten); + if (lineNumber > MaxLines) + { + if (Interlocked.Exchange(ref s_truncated, 1) == 0) + WriteCore($"diagnostics-truncated max-lines={MaxLines}"); + return; + } + } + + WriteCore(message); + } + + private static void WriteCore(string message) + { + var line = $"{DateTimeOffset.UtcNow:O} pid={Environment.ProcessId} tid={Environment.CurrentManagedThreadId} {message}"; + try + { + if (LogPath is { Length: > 0 }) + { + lock (Gate) + { + Writer.Value?.WriteLine(line); + } + } + else + { + Trace.WriteLine(line); + } + } + catch (Exception) + { + // Diagnostics must never change ReadyToRun parsing behavior. + } + } + + private static bool IsEnabled(string? value) => + value is not null + && !string.Equals(value, "0", StringComparison.OrdinalIgnoreCase) + && !string.Equals(value, "false", StringComparison.OrdinalIgnoreCase); + + private static string? NormalizePath(string? value) => + string.IsNullOrWhiteSpace(value) ? null : Path.GetFullPath(value); + + private static int ResolveMaxLines(string? value) => + int.TryParse(value, out var maxLines) && maxLines >= 0 ? maxLines : DefaultMaxLines; + + private static StreamWriter? CreateWriter() + { + if (LogPath is not { Length: > 0 }) + return null; + + try + { + Directory.CreateDirectory(Path.GetDirectoryName(LogPath) ?? "."); + var writer = new StreamWriter(new FileStream(LogPath, FileMode.Append, FileAccess.Write, FileShare.ReadWrite)) + { + AutoFlush = true + }; + writer.WriteLine($"{DateTimeOffset.UtcNow:O} pid={Environment.ProcessId} diagnostics-start"); + return writer; + } + catch (Exception) + { + return null; + } + } +} diff --git a/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunImageReader.cs b/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunImageReader.cs index f98b05fd..2e1b9591 100644 --- a/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunImageReader.cs +++ b/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunImageReader.cs @@ -193,7 +193,7 @@ private static ReadyToRunModel BuildComponent(AssemblyAnalyzer analyzer, ReadyTo ? new ReadyToRunMethodMapReader.GlobalInstanceSource( io, instance.Size, manifestReader, owner.AssemblyName ?? "", Guid.Empty, []) : (ReadyToRunMethodMapReader.GlobalInstanceSource?)null; - allMethods = SafeBuild(tables, sources, global, moduleContext); + allMethods = SafeBuild(tables, sources, global, moduleContext, mvid); } finally { @@ -247,13 +247,14 @@ private static List SafeBuild( Tables tables, IReadOnlyList sources, ReadyToRunMethodMapReader.GlobalInstanceSource? global, - ReadyToRunModuleContext? moduleContext = null) + ReadyToRunModuleContext? moduleContext = null, + Guid? targetMvid = null) { try { return [.. ReadyToRunMethodMapReader.Build( tables.Reader, tables.RuntimeFunctions, tables.HotColdMap, - tables.ImageBase, tables.AddressSpace, sources, global, moduleContext)]; + tables.ImageBase, tables.AddressSpace, sources, global, moduleContext, targetMvid)]; } catch (Exception ex) when (ex is BadImageFormatException or IndexOutOfRangeException or ArgumentOutOfRangeException) { diff --git a/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunImportReader.cs b/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunImportReader.cs index c3670254..b6ea7fc6 100644 --- a/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunImportReader.cs +++ b/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunImportReader.cs @@ -220,6 +220,8 @@ private static void ReadRecord( R2RNativeReader reader, int offset, MetadataReader? metadata, IReadOnlyList methodDefs, ReadyToRunModuleContext? moduleContext) { + Func? resolveMetadata = + moduleContext is null ? null : moduleContext.ResolveMetadata; uint fixup = reader.ReadByte(ref offset); string? modulePrefix = null; if ((fixup & FixupModuleOverride) != 0) @@ -245,7 +247,8 @@ private static void ReadRecord( { case FixupMethodEntry or FixupMethodHandle or FixupVirtualEntry: { - var sig = ReadyToRunSignatureWalker.ParseMethod(reader, offset, metadata); + var sig = ReadyToRunSignatureWalker.ParseMethod( + reader, offset, metadata, resolveMetadata); return DecorateMethod(sig, metadata, methodDefs, modulePrefix); } @@ -257,7 +260,8 @@ private static void ReadRecord( case FixupPInvokeTarget or FixupIndirectPInvokeTarget: { - var sig = ReadyToRunSignatureWalker.ParseMethod(reader, offset, metadata); + var sig = ReadyToRunSignatureWalker.ParseMethod( + reader, offset, metadata, resolveMetadata); var name = DecorateMethod(sig, metadata, methodDefs, modulePrefix); return name is null ? null : $"{name} (pinvoke)"; } diff --git a/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunMethodMapReader.cs b/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunMethodMapReader.cs index 733073ba..1396c8ad 100644 --- a/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunMethodMapReader.cs +++ b/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunMethodMapReader.cs @@ -50,7 +50,8 @@ public static IReadOnlyList Build( NativeAddressSpace addressSpace, IReadOnlyList sources, GlobalInstanceSource? globalInstance, - ReadyToRunModuleContext? moduleContext = null) + ReadyToRunModuleContext? moduleContext = null, + Guid? targetMvid = null) { var isEntryPoint = new bool[runtimeFunctions.Count]; var pending = new List(); @@ -59,9 +60,9 @@ public static IReadOnlyList Build( // methods and the image's instantiated generics — before counting, so funclet grouping // never runs past the map. foreach (var source in sources) - MarkMethodDefEntryPoints(reader, source, isEntryPoint, pending); + MarkMethodDefEntryPoints(reader, source, isEntryPoint, pending, targetMvid); if (globalInstance is { } instance) - MarkInstanceMethodEntryPoints(reader, instance, moduleContext, isEntryPoint, pending); + MarkInstanceMethodEntryPoints(reader, instance, moduleContext, isEntryPoint, pending, targetMvid); // Pass 2/3: count each method's runtime functions, then materialize its code ranges. var methodsByToken = new Dictionary<(string, int), MethodDefInfo>(); @@ -85,7 +86,8 @@ public static IReadOnlyList Build( } private static void MarkMethodDefEntryPoints( - R2RNativeReader reader, MethodMapSource source, bool[] isEntryPoint, List pending) + R2RNativeReader reader, MethodMapSource source, bool[] isEntryPoint, + List pending, Guid? targetMvid) { var array = new R2RNativeArray(reader, source.EntryPointsFileOffset); for (uint rid = 1; rid <= array.Count; rid++) @@ -98,6 +100,9 @@ private static void MarkMethodDefEntryPoints( continue; isEntryPoint[entryId] = true; + if (!ShouldMaterialize(targetMvid, source.Mvid)) + continue; + pending.Add(new PendingEntry( source.AssemblyName, source.Mvid, source, (int)(0x0600_0000 | rid), entryId, IsGeneric: false, Instantiation: null)); @@ -106,16 +111,22 @@ private static void MarkMethodDefEntryPoints( private static void MarkInstanceMethodEntryPoints( R2RNativeReader reader, GlobalInstanceSource instance, ReadyToRunModuleContext? moduleContext, - bool[] isEntryPoint, List pending) + bool[] isEntryPoint, List pending, Guid? targetMvid) { if (instance.Size <= 0) return; var table = new R2RNativeHashtable(reader, instance.Offset, instance.Offset + instance.Size); + Func? resolveMetadata = + moduleContext is null ? null : moduleContext.ResolveMetadata; foreach (var entryOffset in table.AllEntryOffsets()) { // The payload is a method signature followed by the runtime-function index. - var sig = ReadyToRunSignatureWalker.ParseMethod(reader, entryOffset, instance.Metadata); + var metadata = targetMvid is null ? instance.Metadata : null; + Func? metadataResolver = + targetMvid is null ? resolveMetadata : null; + var sig = ReadyToRunSignatureWalker.ParseMethod( + reader, entryOffset, metadata, metadataResolver); var entryId = DecodeRuntimeFunctionIndex(reader, sig.Offset); if (entryId < 0 || entryId >= isEntryPoint.Length) continue; @@ -124,21 +135,37 @@ private static void MarkInstanceMethodEntryPoints( // A module override attributes the instantiation to a component (composite); resolve it // there so its token, name, and owner identity are recovered rather than left unnamed. - if (sig.ModuleIndex >= 0 && moduleContext?.Resolve(sig.ModuleIndex) is { } module) + if (sig.ModuleIndex >= 0) { - var reparsed = module.Provider is { } p - ? ReadyToRunSignatureWalker.ParseMethod(reader, entryOffset, p.GetMetadataReader()) - : sig; - var crossToken = (reparsed.MethodToken & 0xFF00_0000) == 0x0600_0000 ? reparsed.MethodToken : 0; - var source = module.Provider is { } provider - ? new MethodMapSource(module.AssemblyName, module.Mvid, 0, provider.MethodDefs, provider.GetMetadataReader()) - : (MethodMapSource?)null; - pending.Add(new PendingEntry( - module.AssemblyName, module.Mvid, source, crossToken, - entryId, IsGeneric: true, reparsed.InstantiationDisplay)); - continue; + if (moduleContext?.Resolve(sig.ModuleIndex) is not { } module) + { + if (targetMvid is not null) + continue; + } + else + { + if (!ShouldMaterialize(targetMvid, module.Mvid)) + continue; + + var resolvedMetadata = module.Provider?.GetMetadataReader(); + var reparsed = resolvedMetadata is not null + ? ReadyToRunSignatureWalker.ParseMethod( + reader, entryOffset, resolvedMetadata, resolveMetadata) + : sig; + var crossToken = (reparsed.MethodToken & 0xFF00_0000) == 0x0600_0000 ? reparsed.MethodToken : 0; + var source = module.Provider is { } provider + ? new MethodMapSource(module.AssemblyName, module.Mvid, 0, provider.MethodDefs, resolvedMetadata) + : (MethodMapSource?)null; + pending.Add(new PendingEntry( + module.AssemblyName, module.Mvid, source, crossToken, + entryId, IsGeneric: true, reparsed.InstantiationDisplay)); + continue; + } } + if (!ShouldMaterialize(targetMvid, instance.Mvid)) + continue; + // Same-module instantiation — its token is a MethodDef in the instance table's own module; // give it that module's source so its declaring type and name resolve (a cross-module token // we could not resolve stays unnamed). @@ -154,6 +181,9 @@ private static void MarkInstanceMethodEntryPoints( } } + private static bool ShouldMaterialize(Guid? targetMvid, Guid entryMvid) => + targetMvid is null || targetMvid.Value == entryMvid; + private static int DecodeRuntimeFunctionIndex(R2RNativeReader reader, int elementOffset) { var offset = reader.DecodeUnsigned(elementOffset, out var id); diff --git a/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunModuleContext.cs b/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunModuleContext.cs index ad7e83f9..5538f6c6 100644 --- a/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunModuleContext.cs +++ b/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunModuleContext.cs @@ -1,4 +1,5 @@ using Dotsider.Core.Analysis.Models; +using System.Reflection.Metadata; namespace Dotsider.Core.Analysis.ReadyToRun; @@ -57,6 +58,10 @@ private ReadyToRunModuleContext( return new ModuleRef(component.AssemblyName, component.Mvid, _providerFor(component.Mvid)); } + /// Resolves an override index directly to component metadata, or null when unavailable. + public MetadataReader? ResolveMetadata(int moduleIndex) => + Resolve(moduleIndex)?.Provider?.GetMetadataReader(); + // Component assembly indices start at two from R2R major 6.3 (readytorun.h version history); the // manifest reserves index 1 for itself, so a component is index (position + offset). private static int IndexOffset(ReadyToRunInfo info) => diff --git a/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunSignatureWalker.cs b/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunSignatureWalker.cs index 7404b639..f13415f2 100644 --- a/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunSignatureWalker.cs +++ b/src/Dotsider.Core/Analysis/ReadyToRun/ReadyToRunSignatureWalker.cs @@ -21,6 +21,8 @@ internal static class ReadyToRunSignatureWalker private const uint SigConstrained = 0x20; private const uint SigOwnerType = 0x40; private const uint SigUpdateContext = 0x80; + private const uint MaxSignatureItemCount = 1024; + private const uint MaxArrayRank = 256; /// The result of walking one method signature. /// The file offset immediately after the signature (where the runtime-function index begins). @@ -35,18 +37,30 @@ public readonly record struct MethodSignature( /// The image reader. /// The file offset of the signature. /// The metadata reader for resolving token names, or null. - public static MethodSignature ParseMethod(R2RNativeReader reader, int offset, MetadataReader? metadata) + /// Resolves a ReadyToRun module override index to metadata, or null when unavailable. + public static MethodSignature ParseMethod( + R2RNativeReader reader, + int offset, + MetadataReader? metadata, + Func? moduleMetadata = null) { - var walker = new Walker(reader, offset, metadata); + var walker = new Walker(reader, offset, metadata, moduleMetadata); walker.ParseMethod(); return new MethodSignature( walker.Offset, walker.MethodToken, walker.RenderInstantiation(), walker.CrossModule, walker.ModuleIndex); } - private sealed class Walker(R2RNativeReader reader, int offset, MetadataReader? metadata) + private sealed class Walker( + R2RNativeReader reader, + int offset, + MetadataReader? metadata, + Func? moduleMetadata) { private readonly List _instantiation = []; + private readonly MetadataReader? _outerMetadata = metadata; + private readonly int _startOffset = offset; private int _offset = offset; + private MetadataReader? _metadata = metadata; private string? _ownerDisplay; private int _topLevelModuleIndex = -1; @@ -70,13 +84,27 @@ private sealed class Walker(R2RNativeReader reader, int offset, MetadataReader? public void ParseMethod() { var flags = ReadUInt(); + ReadyToRunDiagnostics.Write($"method-start offset=0x{_startOffset:X} flags=0x{flags:X}"); if ((flags & SigUpdateContext) != 0) { _topLevelModuleIndex = (int)ReadUInt(); // the token resolves in the module at this index CrossModule = true; flags &= ~SigUpdateContext; + ReadyToRunDiagnostics.Write( + $"method-update-context start=0x{_startOffset:X} module={_topLevelModuleIndex} next=0x{_offset:X}"); + WithMetadata(moduleMetadata?.Invoke(_topLevelModuleIndex), () => ParseMethodBody(flags)); + ReadyToRunDiagnostics.Write( + $"method-end start=0x{_startOffset:X} end=0x{_offset:X} token=0x{MethodToken:X8} module={ModuleIndex}"); + return; } + ParseMethodBody(flags); + ReadyToRunDiagnostics.Write( + $"method-end start=0x{_startOffset:X} end=0x{_offset:X} token=0x{MethodToken:X8} module={ModuleIndex}"); + } + + private void ParseMethodBody(uint flags) + { if ((flags & SigOwnerType) != 0) { _ownerDisplay = SkipType(); @@ -101,6 +129,7 @@ public void ParseMethod() if ((flags & SigMethodInstantiation) != 0) { var argCount = ReadUInt(); + EnsureBounded(argCount, MaxSignatureItemCount, "method generic argument count"); for (var i = 0; i < argCount; i++) _instantiation.Add(SkipType()); flags &= ~SigMethodInstantiation; @@ -117,6 +146,8 @@ public void ParseMethod() private string SkipType() { var elementType = reader.ReadByte(ref _offset) & 0x7F; + ReadyToRunDiagnostics.Write( + $"type start=0x{_startOffset:X} offset=0x{_offset - 1:X} element=0x{elementType:X2}"); switch (elementType) { case 0x01: return "void"; @@ -151,7 +182,9 @@ private string SkipType() // one seen is the owner's; capture it so the method token attributes correctly. var moduleIndex = (int)ReadUInt(); if (_ownerModuleIndex < 0) { _ownerModuleIndex = moduleIndex; CrossModule = true; } - return SkipType(); + ReadyToRunDiagnostics.Write( + $"type-module start=0x{_startOffset:X} module={moduleIndex} next=0x{_offset:X}"); + return WithMetadata(moduleMetadata?.Invoke(moduleIndex), SkipType); } case 0x3b: ReadUInt(); return "var"; // VAR_ZAPSIG case 0x3d: return SkipType(); // NATIVE_VALUETYPE_ZAPSIG @@ -162,10 +195,13 @@ private string SkipType() { var element = SkipType(); var rank = ReadUInt(); + EnsureBounded(rank, MaxArrayRank, "array rank"); if (rank == 0) return element + "[]"; var sizes = ReadUInt(); + EnsureBounded(sizes, MaxArrayRank, "array size count"); for (var i = 0; i < sizes; i++) ReadUInt(); var lowerBounds = ReadUInt(); + EnsureBounded(lowerBounds, MaxArrayRank, "array lower-bound count"); for (var i = 0; i < lowerBounds; i++) ReadInt(); return $"{element}[{new string(',', (int)rank - 1)}]"; } @@ -173,8 +209,14 @@ private string SkipType() { var generic = SkipType(); var argCount = ReadUInt(); + EnsureBounded(argCount, MaxSignatureItemCount, "type generic argument count"); + ReadyToRunDiagnostics.Write( + $"type-genericinst start=0x{_startOffset:X} args={argCount} next=0x{_offset:X}"); var args = new string[argCount]; - for (var i = 0; i < argCount; i++) args[i] = SkipType(); + WithMetadata(_outerMetadata, () => + { + for (var i = 0; i < argCount; i++) args[i] = SkipType(); + }); return $"{generic}<{string.Join(", ", args)}>"; } case 0x1b: // FNPTR @@ -182,6 +224,7 @@ private string SkipType() var header = reader.ReadByte(ref _offset); if ((header & 0x10) != 0) ReadUInt(); // generic param count var paramCount = ReadUInt(); + EnsureBounded(paramCount, MaxSignatureItemCount, "function pointer parameter count"); SkipType(); // return for (var i = 0; i < paramCount; i++) { @@ -198,25 +241,37 @@ private string SkipType() private string TypeTokenName(int token) { - if (metadata is null) return "Type"; + if (_metadata is null) return "Type"; try { var handle = MetadataTokens.EntityHandle(token); + var row = MetadataTokens.GetRowNumber(handle); + ReadyToRunDiagnostics.Write( + $"type-token offset=0x{_offset:X} token=0x{token:X8} kind={handle.Kind} row={row}"); return handle.Kind switch { - HandleKind.TypeDefinition => metadata.GetString( - metadata.GetTypeDefinition((TypeDefinitionHandle)handle).Name), - HandleKind.TypeReference => metadata.GetString( - metadata.GetTypeReference((TypeReferenceHandle)handle).Name), + HandleKind.TypeDefinition when IsValidRow(row, _metadata.TypeDefinitions.Count) => _metadata.GetString( + _metadata.GetTypeDefinition((TypeDefinitionHandle)handle).Name), + HandleKind.TypeReference when IsValidRow(row, _metadata.TypeReferences.Count) => _metadata.GetString( + _metadata.GetTypeReference((TypeReferenceHandle)handle).Name), _ => "Type", }; } - catch (Exception ex) when (ex is BadImageFormatException or ArgumentException) + catch (Exception ex) when (ex is BadImageFormatException or ArgumentException or InvalidOperationException) { return "Type"; } } + private static void EnsureBounded(uint value, uint max, string name) + { + if (value > max) + throw new BadImageFormatException($"ReadyToRun signature {name} {value} exceeds supported maximum {max}."); + } + + private static bool IsValidRow(int row, int count) => + row > 0 && row <= count; + // Reads a signature token: an ECMA compressed value whose low 2 bits pick the table. private int ReadToken() { @@ -234,5 +289,33 @@ private int ReadToken() private uint ReadUInt() => reader.ReadCompressedUInt(ref _offset); private int ReadInt() => reader.ReadCompressedInt(ref _offset); + + private void WithMetadata(MetadataReader? next, Action action) + { + var previous = _metadata; + _metadata = next; + try + { + action(); + } + finally + { + _metadata = previous; + } + } + + private string WithMetadata(MetadataReader? next, Func action) + { + var previous = _metadata; + _metadata = next; + try + { + return action(); + } + finally + { + _metadata = previous; + } + } } } diff --git a/src/Dotsider.Core/Analysis/RuntimeTracer.cs b/src/Dotsider.Core/Analysis/RuntimeTracer.cs index 7c4738ce..44db5065 100644 --- a/src/Dotsider.Core/Analysis/RuntimeTracer.cs +++ b/src/Dotsider.Core/Analysis/RuntimeTracer.cs @@ -6,6 +6,7 @@ using System.Diagnostics; using System.Diagnostics.Tracing; using System.Globalization; +using System.Runtime.InteropServices; using TraceEventCategory = Dotsider.Core.Analysis.Models.TraceEventCategory; using TraceEventEntry = Dotsider.Core.Analysis.Models.TraceEventEntry; @@ -19,18 +20,20 @@ public sealed class RuntimeTracer(string assemblyPath, string arguments, Action { private const int MaxEvents = 10_000; private const int MaxOutputLines = 5_000; - private const int MaxConnectRetries = 25; // 25 × 200ms = 5s max wait - private const int ConnectRetryDelayMs = 200; + private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(15); + private static readonly TimeSpan ConnectRetryDelay = TimeSpan.FromMilliseconds(200); private readonly bool _isExe = assemblyPath.EndsWith(".exe", StringComparison.OrdinalIgnoreCase); private Process? _process; + private DiagnosticsClientConnector? _connector; private EventPipeSession? _session; private EventPipeEventSource? _eventSource; private Task? _processingTask; private CancellationTokenSource? _cts; private Stopwatch? _stopwatch; private Timer? _invalidateTimer; + private string? _diagnosticPort; // Ring buffer for events — lock protects both read and write private readonly TraceEventEntry[] _eventRing = new TraceEventEntry[MaxEvents]; @@ -54,6 +57,7 @@ public sealed class RuntimeTracer(string assemblyPath, string arguments, Action // Dirty flag for throttled invalidation private int _dirty; + private int _invalidateTimerCallbackActive; // Process output private readonly ConcurrentQueue _outputQueue = new(); @@ -149,6 +153,10 @@ public void Start() { _cts = new CancellationTokenSource(); _stopwatch = Stopwatch.StartNew(); + _diagnosticPort = CreateDiagnosticPort(); + var connectCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token); + connectCts.CancelAfter(ConnectTimeout); + var connectorTask = DiagnosticsClientConnector.FromDiagnosticPort(_diagnosticPort, connectCts.Token); // Launch with diagnostic port suspend so we can attach EventPipe // before Main() runs — this captures events even for short-lived processes @@ -160,7 +168,13 @@ public void Start() RedirectStandardOutput = true, RedirectStandardError = true, }; - psi.Environment["DOTNET_DefaultDiagnosticPortSuspend"] = "1"; + psi.Environment["DOTNET_DiagnosticPorts"] = _diagnosticPort; + psi.Environment.Remove("DOTNET_DefaultDiagnosticPortSuspend"); + if (!psi.Environment.ContainsKey("ASPNETCORE_URLS") + && !psi.Environment.ContainsKey("DOTNET_URLS")) + { + psi.Environment["ASPNETCORE_URLS"] = "http://127.0.0.1:0"; + } _process = Process.Start(psi); if (_process is null) @@ -171,40 +185,29 @@ public void Start() _processState = TraceProcessState.Error; } + connectCts.Cancel(); + connectCts.Dispose(); MarkDirty(); return; } // Capture stdout/stderr on background threads var startTime = _stopwatch; - Task.Run(() => ReadOutput(_process.StandardOutput, false, startTime)); - Task.Run(() => ReadOutput(_process.StandardError, true, startTime)); + RunOnDedicatedThread(() => ReadOutput(_process.StandardOutput, false, startTime)); + RunOnDedicatedThread(() => ReadOutput(_process.StandardError, true, startTime)); // Handle process exit _process.EnableRaisingEvents = true; _process.Exited += (_, _) => { - bool transitioned; + int? exitCode = TryReadExitCode(); lock (_stateLock) { - transitioned = _processState == TraceProcessState.Running; - if (transitioned) - { - _exitCode = _process.HasExited ? _process.ExitCode : null; - _processState = TraceProcessState.Exited; - } + _exitCode = exitCode; } - if (transitioned) - invalidate(); - if (transitioned) - { - _stopwatch?.Stop(); - // StopProcessing unblocks source.Process() synchronously. - // session.Stop() can deadlock on Windows when the pipe is - // already broken, so we only use the TraceEventSource path. - _eventSource?.StopProcessing(); - } + _stopwatch?.Stop(); + invalidate(); }; lock (_stateLock) _processState = TraceProcessState.Starting; @@ -215,51 +218,41 @@ public void Start() // Otherwise, only invalidate when the dirty flag is set. _invalidateTimer = new Timer(_ => { - if (Interlocked.Exchange(ref _dirty, 0) == 1 - || ProcessState == TraceProcessState.Running) - invalidate(); - }, null, 0, 100); + if (Interlocked.Exchange(ref _invalidateTimerCallbackActive, 1) == 1) + { + return; + } - // Connection + event processing on background task. - // The process is suspended (DOTNET_DefaultDiagnosticPortSuspend=1) - // so it won't exit while we're connecting. - var providers = BuildProviders(); - var pid = _process.Id; - _processingTask = Task.Run(async () => - { try { - // Retry connecting — the diagnostic IPC endpoint may not be - // available immediately after process start - DiagnosticsClient? client = null; - EventPipeSession? session = null; - - for (var attempt = 0; attempt < MaxConnectRetries; attempt++) + if (Interlocked.Exchange(ref _dirty, 0) == 1 + || IsProcessRunning()) { - _cts.Token.ThrowIfCancellationRequested(); - - try - { - client = new DiagnosticsClient(pid); - session = client.StartEventPipeSession(providers, requestRundown: false); - break; // connected - } - catch (ServerNotAvailableException) - { - // Runtime diagnostic pipe not ready yet — wait and retry - await Task.Delay(ConnectRetryDelayMs, _cts.Token); - } - catch (EndOfStreamException) - { - await Task.Delay(ConnectRetryDelayMs, _cts.Token); - } + invalidate(); } + } + finally + { + Volatile.Write(ref _invalidateTimerCallbackActive, 0); + } + }, null, 0, 100); + // Connection + event processing on background task. The process is + // suspended by DOTNET_DiagnosticPorts until we attach EventPipe and + // send ResumeRuntime. + var providers = BuildProviders(); + _processingTask = Task.Factory.StartNew(async () => + { + try + { + _connector = await connectorTask.ConfigureAwait(false); + var client = _connector.Instance; + var session = await TryStartEventPipeSessionAsync(client, providers); if (session is null) { lock (_stateLock) { - _errorMessage = "Timed out connecting to runtime diagnostics (5s). Is this a valid .NET assembly?"; + _errorMessage = $"Timed out starting runtime diagnostics after {ConnectTimeout.TotalSeconds.ToString("0", CultureInfo.InvariantCulture)}s. Is this a valid .NET assembly?"; _processState = TraceProcessState.Error; } @@ -273,23 +266,27 @@ public void Start() MarkDirty(); // Resume the suspended runtime now that EventPipe is attached - client!.ResumeRuntime(); + client.ResumeRuntime(); // Process events — blocks until session ends ProcessEventsLoop(session); + MarkExitedIfStarted(); } - catch (OperationCanceledException) { /* user cancelled */ } - catch (Exception ex) when (ex is EndOfStreamException or IOException or ObjectDisposedException) + catch (OperationCanceledException) when (_cts.IsCancellationRequested) { /* user cancelled */ } + catch (OperationCanceledException) { - // Expected: process exited (pipe broke) or user cancelled lock (_stateLock) { - if (_processState is TraceProcessState.Running or TraceProcessState.Starting) - { - _exitCode = _process?.HasExited == true ? _process.ExitCode : null; - _processState = TraceProcessState.Exited; - } + _errorMessage = $"Timed out connecting to runtime diagnostics after {ConnectTimeout.TotalSeconds.ToString("0", CultureInfo.InvariantCulture)}s. Is this a valid .NET assembly?"; + _processState = TraceProcessState.Error; } + + try { _process.Kill(); } catch { } + } + catch (Exception ex) when (ex is EndOfStreamException or IOException or ObjectDisposedException) + { + // Expected: process exited (pipe broke) or user cancelled + MarkExitedIfStarted(); } catch (Exception ex) { @@ -301,10 +298,13 @@ public void Start() } finally { + connectCts.Dispose(); + DisposeDiagnosticConnector(); + CleanupDiagnosticPort(); _stopwatch?.Stop(); invalidate(); } - }); + }, CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default).Unwrap(); } /// Stops the traced process and event collection. @@ -325,15 +325,18 @@ public void Stop() _invalidateTimer?.Dispose(); _invalidateTimer = null; + DisposeDiagnosticConnector(); + CleanupDiagnosticPort(); _stopwatch?.Stop(); + int? exitCode = TryReadExitCode(); bool stopped; lock (_stateLock) { stopped = _processState is TraceProcessState.Running or TraceProcessState.Starting; if (stopped) { - _exitCode = _process?.HasExited == true ? _process.ExitCode : null; + _exitCode = exitCode; _processState = TraceProcessState.Exited; } } @@ -353,6 +356,122 @@ public void Dispose() private void MarkDirty() => Volatile.Write(ref _dirty, 1); + private static string CreateDiagnosticPort() + { + var name = $"dotsider-{Environment.ProcessId}-{Guid.NewGuid():N}"; + return RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? name + : Path.Combine("/tmp", $"{name}.socket"); + } + + private void DisposeDiagnosticConnector() + { + var connector = Interlocked.Exchange(ref _connector, null); + if (connector is null) + return; + + try { connector.DisposeAsync().AsTask().GetAwaiter().GetResult(); } catch { } + } + + private void CleanupDiagnosticPort() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + return; + + var diagnosticPort = _diagnosticPort; + if (string.IsNullOrEmpty(diagnosticPort)) + return; + + try { File.Delete(diagnosticPort); } catch { } + } + + private void MarkExitedIfStarted() + { + int? exitCode = TryReadExitCode(); + lock (_stateLock) + { + if (_processState is TraceProcessState.Running or TraceProcessState.Starting) + { + _exitCode = exitCode; + _processState = TraceProcessState.Exited; + } + } + } + + private bool IsProcessRunning() + { + lock (_stateLock) + { + return _processState == TraceProcessState.Running; + } + } + + private int? TryReadExitCode() + { + var process = _process; + if (process is null) + { + return null; + } + + try + { + return process.HasExited ? process.ExitCode : null; + } + catch (InvalidOperationException) + { + return null; + } + } + + private async Task TryStartEventPipeSessionAsync( + DiagnosticsClient client, + IReadOnlyCollection providers) + { + var connectTimer = Stopwatch.StartNew(); + + while (connectTimer.Elapsed < ConnectTimeout) + { + _cts!.Token.ThrowIfCancellationRequested(); + + try + { + using var connectCts = CancellationTokenSource.CreateLinkedTokenSource(_cts.Token); + connectCts.CancelAfter(ConnectTimeout - connectTimer.Elapsed); + return await client.StartEventPipeSessionAsync( + providers, + requestRundown: false, + circularBufferMB: 256, + token: connectCts.Token); + } + catch (OperationCanceledException) when (!_cts.IsCancellationRequested) + { + return null; + } + catch (Exception ex) when (IsTransientDiagnosticsConnectException(ex)) + { + var remaining = ConnectTimeout - connectTimer.Elapsed; + if (remaining <= TimeSpan.Zero) + return null; + + var delay = remaining < ConnectRetryDelay ? remaining : ConnectRetryDelay; + await Task.Delay(delay, _cts.Token); + } + } + + return null; + } + + private static bool IsTransientDiagnosticsConnectException(Exception ex) => + ex is ServerNotAvailableException or EndOfStreamException; + + private static void RunOnDedicatedThread(Action action) => + Task.Factory.StartNew( + action, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default); + private static List BuildProviders() => [ // Verbose level is required for MethodJittingStarted events (which diff --git a/src/Dotsider.Core/Dotsider.Core.csproj b/src/Dotsider.Core/Dotsider.Core.csproj index 24056e36..b0109c4b 100644 --- a/src/Dotsider.Core/Dotsider.Core.csproj +++ b/src/Dotsider.Core/Dotsider.Core.csproj @@ -18,7 +18,7 @@ - + diff --git a/src/Dotsider.Mcp/Dotsider.Mcp.csproj b/src/Dotsider.Mcp/Dotsider.Mcp.csproj index d41a283e..39b54153 100644 --- a/src/Dotsider.Mcp/Dotsider.Mcp.csproj +++ b/src/Dotsider.Mcp/Dotsider.Mcp.csproj @@ -37,8 +37,8 @@ - - + + diff --git a/src/Dotsider/Dotsider.csproj b/src/Dotsider/Dotsider.csproj index 22ecf33b..d75e2c2f 100644 --- a/src/Dotsider/Dotsider.csproj +++ b/src/Dotsider/Dotsider.csproj @@ -44,7 +44,7 @@ - + diff --git a/src/Dotsider/DotsiderApp.cs b/src/Dotsider/DotsiderApp.cs index a661c223..dc6e7e63 100644 --- a/src/Dotsider/DotsiderApp.cs +++ b/src/Dotsider/DotsiderApp.cs @@ -103,6 +103,12 @@ .. _state.Analyzer.PreIlcCompanions is { } companions ]) .OnSelectionChanged(e => { + if (IsDetailPopupOpen()) + { + _state.App.Invalidate(); + return; + } + SelectTab(e.SelectedIndex); SeedFocusedRowIfNeeded(); RequestContentFocus(); @@ -137,38 +143,42 @@ Action VimReset(Action act // Number keys 1-8, s, q suppressed during search editing, jump dialog, // or hex insert mode to let EditorNode/TextBox receive character input var hexInsertMode = _state.CurrentTab == TabId.HexDump && _state.HexMode == HexEditMode.Insert; + var detailPopupOpen = IsDetailPopupOpen(); if (!isSearchEditing && !_state.HexJumpDialogOpen && !hexInsertMode && !_state.ModalDialogOpen) { - for (var i = 0; i < 8; i++) + if (!detailPopupOpen) { - var tabIndex = i; - var key = (Hex1bKey)((int)Hex1bKey.D1 + i); - bindings.Key(key).Global().Action(VimReset(_ => + for (var i = 0; i < 8; i++) { - SelectTab(tabIndex); - SeedFocusedRowIfNeeded(); - RequestContentFocus(); - _state.App.Invalidate(); - }), $"Tab {tabIndex + 1}"); - } + var tabIndex = i; + var key = (Hex1bKey)((int)Hex1bKey.D1 + i); + bindings.Key(key).Global().Action(VimReset(_ => + { + SelectTab(tabIndex); + SeedFocusedRowIfNeeded(); + RequestContentFocus(); + _state.App.Invalidate(); + }), $"Tab {tabIndex + 1}"); + } - // Suppress size toggle on Dynamic Events sub-tab (S = Socket filter) - // and hex tab in insert mode (S = byte input) - var suppressSizeToggle = (_state.CurrentTab == TabId.Dynamic && _state.DynamicSubTab == DynamicSubTabId.Events) - || (_state.CurrentTab == TabId.HexDump && _state.HexMode == HexEditMode.Insert); - if (!suppressSizeToggle) - { - bindings.Key(Hex1bKey.S).Global().Action(VimReset(_ => + // Suppress size toggle on Dynamic Events sub-tab (S = Socket filter) + // and hex tab in insert mode (S = byte input) + var suppressSizeToggle = (_state.CurrentTab == TabId.Dynamic && _state.DynamicSubTab == DynamicSubTabId.Events) + || (_state.CurrentTab == TabId.HexDump && _state.HexMode == HexEditMode.Insert); + if (!suppressSizeToggle) { - _state.HumanReadableSizes = !_state.HumanReadableSizes; - _state.App.Invalidate(); - }), "Toggle size format"); - } + bindings.Key(Hex1bKey.S).Global().Action(VimReset(_ => + { + _state.HumanReadableSizes = !_state.HumanReadableSizes; + _state.App.Invalidate(); + }), "Toggle size format"); + } - // Suppress Q quit in hex insert mode — let editor receive it as byte input - if (!(_state.CurrentTab == TabId.HexDump && _state.HexMode == HexEditMode.Insert)) - bindings.Key(Hex1bKey.Q).Global().Action(VimReset(ctx => ctx.RequestStop()), "Quit"); + // Suppress Q quit in hex insert mode — let editor receive it as byte input + if (!(_state.CurrentTab == TabId.HexDump && _state.HexMode == HexEditMode.Insert)) + bindings.Key(Hex1bKey.Q).Global().Action(VimReset(ctx => ctx.RequestStop()), "Quit"); + } // Universal yank — works on all tabs with neovim-style behavior bindings.Key(Hex1bKey.Y).Global().Action(ctx => @@ -250,7 +260,6 @@ void SearchToggle() _state.App.RequestFocus(node => node is TextBoxNode); _state.App.Invalidate(); } - var detailPopupOpen = _state.PeDetailContent is not null || _state.StringsDetailContent is not null; if (!_state.HexJumpDialogOpen && !detailPopupOpen && !_state.ModalDialogOpen) { bindings.Key(Hex1bKey.OemQuestion).Global().OverridesCapture().Action(VimReset(_ => SearchToggle()), "Search"); @@ -306,6 +315,19 @@ void SearchToggle() }), "Attach pre-ILC sidecars"); } + if (_state.CurrentTab == TabId.General + && !isSearchEditing + && !_state.ApphostDialogOpen + && !_state.PreIlcDialogOpen + && !_state.ModalDialogOpen) + { + bindings.Key(Hex1bKey.Enter).Global().OverridesCapture().Action(VimReset(_ => + { + if (_state.App.FocusedNode is EditorNode) return; + TryDrillFocusedGeneralReference(); + }), "Drill into reference"); + } + // Hex + IL Inspector keybindings (shared with NuGetApp). // Suppressed while a companion dialog is open to prevent background shortcuts. if (!isSearchEditing && !_state.ModalDialogOpen) @@ -546,12 +568,44 @@ private void SelectTab(int tabIndex) _state.NavigateToTab(tabIndex); } + private bool IsDetailPopupOpen() => + _state.PeDetailContent is not null || _state.StringsDetailContent is not null; + /// /// Requests focus on the appropriate content node for the current tab. /// IL tab targets the ListNode tree; all other tabs target any content node including TableNode. /// private void RequestContentFocus() => _state.RequestContentFocus(); + private bool TryDrillFocusedGeneralReference() + { + var refs = _state.MetadataAnalyzer.AssemblyRefs; + var focusedName = _state.GeneralFocusedDep as string + ?? (refs.Count > 0 ? refs[0].Name : null); + if (focusedName is null) return false; + + var asmRef = refs.FirstOrDefault( + r => string.Equals(r.Name, focusedName, StringComparison.OrdinalIgnoreCase)); + if (asmRef is null) return false; + + var resolution = AssemblyAnalyzer.ResolveAssemblyByIdentity( + _state.MetadataAnalyzer.FilePath, + asmRef, + _state.MetadataAnalyzer.TargetFramework, + _state.MetadataAnalyzer.PreferredRuntimePack, + _state.MetadataAnalyzer.SourceBundlePath, + _state.RootNetFxBindingContext); + if (resolution.Resolved is null) return false; + + if (!_state.PushAssembly(resolution.Resolved)) return false; + + SeedFocusedRowIfNeeded(); + RequestContentFocus(); + _state.App.Invalidate(); + _state.RequestExtraFrame(); + return true; + } + /// /// Seeds the focused row key for table-backed tabs so Enter works immediately /// without requiring DownArrow first. diff --git a/src/Dotsider/DotsiderState.cs b/src/Dotsider/DotsiderState.cs index 2087cc7c..973f5e23 100644 --- a/src/Dotsider/DotsiderState.cs +++ b/src/Dotsider/DotsiderState.cs @@ -970,7 +970,7 @@ public void EnsureManagedNativeIndexAsync() if (capturedAnalyzer.PreIlcCompanions is null) return; PreIlcIndexBuildInProgress = true; - _ = Task.Run(() => + _ = QueueDedicatedBackgroundWork(() => { try { @@ -993,6 +993,7 @@ public void EnsureManagedNativeIndexAsync() { PreIlcIndexBuildInProgress = false; App.Invalidate(); + RequestExtraFrame(); } }); } @@ -1216,8 +1217,10 @@ public void NavigateToIlMethod(MethodDefInfo method) NavigateToTab(TabId.IlInspector); var ilSearch = Search[TabId.IlInspector]; ilSearch.Reset(); + IlFocusedPane = IlPane.Tree; App.RequestFocus(node => node is ScrollPanelNode); App.Invalidate(); + RequestExtraFrame(); } /// @@ -1245,6 +1248,7 @@ public void NavigateToHexOffset(int rva) NavigateToTab(TabId.HexDump); App.RequestFocus(node => node is EditorNode); App.Invalidate(); + RequestExtraFrame(); } /// Navigates to the Hex Dump tab, jumping directly to a file offset (native mode). @@ -1268,6 +1272,7 @@ public void NavigateToHexFileOffset(long fileOffset) NavigateToTab(TabId.HexDump); App.RequestFocus(node => node is EditorNode); App.Invalidate(); + RequestExtraFrame(); } /// @@ -1943,6 +1948,7 @@ public void NavigateBack() // single source of truth — IL → ScrollPanelNode, Hex → EditorNode, etc. RequestContentFocus(); App.Invalidate(); + RequestExtraFrame(); } /// @@ -2322,7 +2328,7 @@ public void EnsureCachedGraphAsync() GraphBuildInProgress = true; var capturedAnalyzer = Analyzer; - _ = Task.Run(() => + _ = QueueDedicatedBackgroundWork(() => { try { @@ -2338,7 +2344,15 @@ public void EnsureCachedGraphAsync() { GraphBuildInProgress = false; App.Invalidate(); + RequestExtraFrame(); } }); } + + private static Task QueueDedicatedBackgroundWork(Action work) => + Task.Factory.StartNew( + work, + CancellationToken.None, + TaskCreationOptions.DenyChildAttach | TaskCreationOptions.LongRunning, + TaskScheduler.Default); } diff --git a/src/Dotsider/Infrastructure/CursorColorHelper.cs b/src/Dotsider/Infrastructure/CursorColorHelper.cs index 36eb461a..300dc973 100644 --- a/src/Dotsider/Infrastructure/CursorColorHelper.cs +++ b/src/Dotsider/Infrastructure/CursorColorHelper.cs @@ -14,10 +14,30 @@ public static class CursorColorHelper /// /// Writes the OSC 12 sequence to set the cursor color to the dotsider theme teal. /// - public static void SetThemeCursorColor() => Console.Write(SetTealSequence); + public static void SetThemeCursorColor() => SetThemeCursorColor(Console.Out); + + /// + /// Writes the OSC 12 sequence to set the cursor color to the dotsider theme teal. + /// + /// The writer that receives the escape sequence. + public static void SetThemeCursorColor(TextWriter writer) + { + ArgumentNullException.ThrowIfNull(writer); + writer.Write(SetTealSequence); + } + + /// + /// Writes the OSC 112 sequence to reset the cursor color to the terminal default. + /// + public static void ResetCursorColor() => ResetCursorColor(Console.Out); /// /// Writes the OSC 112 sequence to reset the cursor color to the terminal default. /// - public static void ResetCursorColor() => Console.Write(ResetSequence); + /// The writer that receives the escape sequence. + public static void ResetCursorColor(TextWriter writer) + { + ArgumentNullException.ThrowIfNull(writer); + writer.Write(ResetSequence); + } } diff --git a/src/Dotsider/NuGetApp.cs b/src/Dotsider/NuGetApp.cs index 96937d60..2d8da20c 100644 --- a/src/Dotsider/NuGetApp.cs +++ b/src/Dotsider/NuGetApp.cs @@ -286,7 +286,7 @@ void SearchToggle() if (!_state.IsBrowsingPackage && _state.SelectedDllState is not null) { - if (!isSearchEditing) + if (!isSearchEditing && !IsDetailPopupOpen(_state.SelectedDllState)) { for (var i = 0; i < 5; i++) { @@ -525,10 +525,19 @@ private Hex1bWidget BuildDllInspector(WidgetContext outer) ]) .OnSelectionChanged(e => { + if (IsDetailPopupOpen(dllState)) + { + _state.App.Invalidate(); + return; + } + dllState.CurrentTab = e.SelectedIndex; _state.App.Invalidate(); }) .Full() .Fill(); } + + private static bool IsDetailPopupOpen(DotsiderState state) => + state.PeDetailContent is not null || state.StringsDetailContent is not null; } diff --git a/src/Dotsider/Views/DiffMethodsView.cs b/src/Dotsider/Views/DiffMethodsView.cs index 4e946714..16a5a483 100644 --- a/src/Dotsider/Views/DiffMethodsView.cs +++ b/src/Dotsider/Views/DiffMethodsView.cs @@ -45,23 +45,8 @@ public static Hex1bWidget Build(WidgetContext ctx, DiffState state // Set up match navigation — cycle through filtered rows if (filtered.Count > 0 && !string.IsNullOrEmpty(query)) { - var keys = filtered.Select(e => - { - var m = e.Right ?? e.Left!; - return e.Kind.ToString() + ":" + m.DeclaringType + "::" + m.Name + m.Signature; - }).ToList(); - state.NavigateNextMatch = () => - { - var idx = keys.IndexOf(state.DiffFocusedKey as string ?? ""); - idx = (idx + 1) % keys.Count; - state.DiffFocusedKey = keys[idx]; - }; - state.NavigatePrevMatch = () => - { - var idx = keys.IndexOf(state.DiffFocusedKey as string ?? ""); - idx = idx <= 0 ? keys.Count - 1 : idx - 1; - state.DiffFocusedKey = keys[idx]; - }; + state.NavigateNextMatch = () => NavigateMatch(state, forward: true); + state.NavigatePrevMatch = () => NavigateMatch(state, forward: false); } else { @@ -78,8 +63,7 @@ public static Hex1bWidget Build(WidgetContext ctx, DiffState state // Table widgets.Add(outer.Table(filtered) - .RowKey(r => r.Kind.ToString() + ":" + (r.Left?.DeclaringType ?? r.Right?.DeclaringType ?? "") + "::" + - (r.Left?.Name ?? r.Right?.Name ?? "") + (r.Left?.Signature ?? r.Right?.Signature ?? "")) + .RowKey(KeyFor) .Header(h => [ h.Cell("").Width(SizeHint.Fixed(3)), @@ -144,6 +128,42 @@ private static IReadOnlyList> FilterEntries( _ => entries }; + private static List GetMatchingKeys(DiffState state) + { + var search = state.Search[2]; + var query = search.Query; + if (string.IsNullOrEmpty(query)) return []; + + return [.. FilterEntries(state.DiffResult.MethodDiffs, state.FilterMode) + .Where(e => + { + var method = e.Right ?? e.Left!; + return method.Name.Contains(query, StringComparison.OrdinalIgnoreCase) || + method.Signature.Contains(query, StringComparison.OrdinalIgnoreCase) || + method.DeclaringType.Contains(query, StringComparison.OrdinalIgnoreCase); + }) + .Select(KeyFor)]; + } + + private static void NavigateMatch(DiffState state, bool forward) + { + var keys = GetMatchingKeys(state); + if (keys.Count == 0) return; + + var idx = keys.IndexOf(state.DiffFocusedKey as string ?? ""); + idx = forward + ? (idx + 1) % keys.Count + : idx <= 0 ? keys.Count - 1 : idx - 1; + state.DiffFocusedKey = keys[idx]; + } + + private static string KeyFor(DiffEntry entry) => + entry.Kind + ":" + + (entry.Left?.DeclaringType ?? entry.Right?.DeclaringType ?? "") + + "::" + + (entry.Left?.Name ?? entry.Right?.Name ?? "") + + (entry.Left?.Signature ?? entry.Right?.Signature ?? ""); + private static (string Prefix, Hex1bColor Color) GetDiffStyle(DiffKind kind) => kind switch { DiffKind.Added => ("+", Green), diff --git a/src/Dotsider/Views/DiffRefsView.cs b/src/Dotsider/Views/DiffRefsView.cs index bc3eb7ce..6c0c0020 100644 --- a/src/Dotsider/Views/DiffRefsView.cs +++ b/src/Dotsider/Views/DiffRefsView.cs @@ -46,20 +46,8 @@ public static Hex1bWidget Build(WidgetContext ctx, DiffState state // Set up match navigation — cycle through filtered rows if (filtered.Count > 0 && !string.IsNullOrEmpty(query)) { - var keys = filtered.Select(e => - e.Kind.ToString() + ":" + (e.Left?.Name ?? e.Right?.Name ?? "")).ToList(); - state.NavigateNextMatch = () => - { - var idx = keys.IndexOf(state.DiffFocusedKey as string ?? ""); - idx = (idx + 1) % keys.Count; - state.DiffFocusedKey = keys[idx]; - }; - state.NavigatePrevMatch = () => - { - var idx = keys.IndexOf(state.DiffFocusedKey as string ?? ""); - idx = idx <= 0 ? keys.Count - 1 : idx - 1; - state.DiffFocusedKey = keys[idx]; - }; + state.NavigateNextMatch = () => NavigateMatch(state, forward: true); + state.NavigatePrevMatch = () => NavigateMatch(state, forward: false); } else { @@ -76,7 +64,7 @@ public static Hex1bWidget Build(WidgetContext ctx, DiffState state // Table widgets.Add(outer.Table(filtered) - .RowKey(r => r.Kind.ToString() + ":" + (r.Left?.Name ?? r.Right?.Name ?? "")) + .RowKey(KeyFor) .Header(h => [ h.Cell("").Width(SizeHint.Fixed(3)), @@ -141,6 +129,39 @@ private static IReadOnlyList> FilterEntries( _ => entries }; + private static List GetMatchingKeys(DiffState state) + { + var search = state.Search[3]; + var query = search.Query; + if (string.IsNullOrEmpty(query)) return []; + + return [.. FilterEntries(state.DiffResult.AssemblyRefDiffs, state.FilterMode) + .Where(e => + { + var name = e.Right?.Name ?? e.Left?.Name ?? ""; + var leftVer = e.Left?.Version ?? ""; + var rightVer = e.Right?.Version ?? ""; + return $"{name} {leftVer} {rightVer}" + .Contains(query, StringComparison.OrdinalIgnoreCase); + }) + .Select(KeyFor)]; + } + + private static void NavigateMatch(DiffState state, bool forward) + { + var keys = GetMatchingKeys(state); + if (keys.Count == 0) return; + + var idx = keys.IndexOf(state.DiffFocusedKey as string ?? ""); + idx = forward + ? (idx + 1) % keys.Count + : idx <= 0 ? keys.Count - 1 : idx - 1; + state.DiffFocusedKey = keys[idx]; + } + + private static string KeyFor(DiffEntry entry) => + entry.Kind + ":" + (entry.Left?.Name ?? entry.Right?.Name ?? ""); + private static (string Prefix, Hex1bColor Color) GetDiffStyle(DiffKind kind) => kind switch { DiffKind.Added => ("+", Green), diff --git a/src/Dotsider/Views/DiffTypesView.cs b/src/Dotsider/Views/DiffTypesView.cs index 370bf4a5..6dfb5845 100644 --- a/src/Dotsider/Views/DiffTypesView.cs +++ b/src/Dotsider/Views/DiffTypesView.cs @@ -43,20 +43,8 @@ public static Hex1bWidget Build(WidgetContext ctx, DiffState state // Set up match navigation — cycle through filtered rows if (filtered.Count > 0 && !string.IsNullOrEmpty(query)) { - var keys = filtered.Select(e => - e.Kind.ToString() + ":" + (e.Left?.FullName ?? e.Right?.FullName ?? "")).ToList(); - state.NavigateNextMatch = () => - { - var idx = keys.IndexOf(state.DiffFocusedKey as string ?? ""); - idx = (idx + 1) % keys.Count; - state.DiffFocusedKey = keys[idx]; - }; - state.NavigatePrevMatch = () => - { - var idx = keys.IndexOf(state.DiffFocusedKey as string ?? ""); - idx = idx <= 0 ? keys.Count - 1 : idx - 1; - state.DiffFocusedKey = keys[idx]; - }; + state.NavigateNextMatch = () => NavigateMatch(state, forward: true); + state.NavigatePrevMatch = () => NavigateMatch(state, forward: false); } else { @@ -73,7 +61,7 @@ public static Hex1bWidget Build(WidgetContext ctx, DiffState state // Table widgets.Add(outer.Table(filtered) - .RowKey(r => r.Kind.ToString() + ":" + (r.Left?.FullName ?? r.Right?.FullName ?? "")) + .RowKey(KeyFor) .Header(h => [ h.Cell("").Width(SizeHint.Fixed(3)), @@ -138,6 +126,36 @@ private static IReadOnlyList> FilterEntries( _ => entries }; + private static List GetMatchingKeys(DiffState state) + { + var search = state.Search[1]; + var query = search.Query; + if (string.IsNullOrEmpty(query)) return []; + + return [.. FilterEntries(state.DiffResult.TypeDiffs, state.FilterMode) + .Where(e => + { + var type = e.Right ?? e.Left!; + return type.FullName.Contains(query, StringComparison.OrdinalIgnoreCase); + }) + .Select(KeyFor)]; + } + + private static void NavigateMatch(DiffState state, bool forward) + { + var keys = GetMatchingKeys(state); + if (keys.Count == 0) return; + + var idx = keys.IndexOf(state.DiffFocusedKey as string ?? ""); + idx = forward + ? (idx + 1) % keys.Count + : idx <= 0 ? keys.Count - 1 : idx - 1; + state.DiffFocusedKey = keys[idx]; + } + + private static string KeyFor(DiffEntry entry) => + entry.Kind + ":" + (entry.Left?.FullName ?? entry.Right?.FullName ?? ""); + private static (string Prefix, Hex1bColor Color) GetDiffStyle(DiffKind kind) => kind switch { DiffKind.Added => ("+", Green), diff --git a/src/Dotsider/Views/GeneralView.cs b/src/Dotsider/Views/GeneralView.cs index 4bd98c21..4297f7fe 100644 --- a/src/Dotsider/Views/GeneralView.cs +++ b/src/Dotsider/Views/GeneralView.cs @@ -303,7 +303,9 @@ Hex1bWidget BuildReferencesPanel(WidgetContext outer) if (resolution.Resolved is not null) { state.PushAssembly(resolution.Resolved); + state.RequestContentFocus(); state.App.Invalidate(); + state.RequestExtraFrame(); } }) .Compact() @@ -330,7 +332,9 @@ Hex1bWidget BuildReferencesPanel(WidgetContext outer) if (resolution.Resolved is not null) { state.PushAssembly(resolution.Resolved); + state.RequestContentFocus(); state.App.Invalidate(); + state.RequestExtraFrame(); } }, "Drill into reference"); }) diff --git a/src/Dotsider/Views/HexDumpView.cs b/src/Dotsider/Views/HexDumpView.cs index 4b44e222..66108d04 100644 --- a/src/Dotsider/Views/HexDumpView.cs +++ b/src/Dotsider/Views/HexDumpView.cs @@ -124,6 +124,7 @@ public static Hex1bWidget Build(WidgetContext ctx, DotsiderState s state.HexMatchPatternLength = 0; state.HexLastSearchQuery = null; state.HexLiveSearchTooSlow = false; + state.RequestContentFocus(); state.App.Invalidate(); } }, "Esc"); diff --git a/src/Dotsider/Views/IlInspectorView.cs b/src/Dotsider/Views/IlInspectorView.cs index 852ec9b7..180db0df 100644 --- a/src/Dotsider/Views/IlInspectorView.cs +++ b/src/Dotsider/Views/IlInspectorView.cs @@ -199,57 +199,46 @@ public static Hex1bWidget Build(WidgetContext ctx, DotsiderState s IlTreeList.Build( treeRows, formattedRows, + getRows: () => IsNativeTreeMode(state) ? BuildNativeTreeRows(state) : BuildTreeRows(state), state, - selectionChanged: index => + selectionChanged: row => { - if (index >= 0 && index < treeRows.Count) + // Direct assignment on keyboard/click moves does NOT arm + // IlScrollSelectionIntoViewPending. The keyboard handler + // calls EnsureSelectionVisible inline, so the pending + // path is reserved for external setters. + state.IlFocusedTreeKey = row.Key; + if (row is { Kind: IlTreeRowKind.Method, Method: not null }) { - var row = treeRows[index]; - // Direct assignment on keyboard/click moves — does NOT arm - // IlScrollSelectionIntoViewPending. The keyboard handler - // calls EnsureSelectionVisible inline, so the pending - // path is reserved for external setters. - state.IlFocusedTreeKey = row.Key; - if (row is { Kind: IlTreeRowKind.Method, Method: not null }) - { - state.IlSelectedMethod = row.Method; - state.IlSelectedMethodOwner = row.Owner; - } - else if (row is { Kind: IlTreeRowKind.Method, Symbol: not null }) - { - state.IlSelectedNativeSymbol = row.Symbol; - } + state.IlSelectedMethod = row.Method; + state.IlSelectedMethodOwner = row.Owner; } + else if (row is { Kind: IlTreeRowKind.Method, Symbol: not null }) + { + state.IlSelectedNativeSymbol = row.Symbol; + } + state.App.Invalidate(); }, - itemActivated: index => + itemActivated: row => { - if (index >= 0 && index < treeRows.Count) - ActivateTreeRow(treeRows[index], state); + ActivateTreeRow(row, state); state.App.Invalidate(); }, - expandRow: index => + expandRow: row => { - if (index >= 0 && index < treeRows.Count) + if (row is { CanExpand: true, IsExpanded: false }) { - var row = treeRows[index]; - if (row is { CanExpand: true, IsExpanded: false }) - { - state.IlTreeExpansionState[row.ExpansionKey] = true; - state.App.Invalidate(); - } + state.IlTreeExpansionState[row.ExpansionKey] = true; + state.App.Invalidate(); } }, - collapseRow: index => + collapseRow: row => { - if (index >= 0 && index < treeRows.Count) + if (row is { CanExpand: true, IsExpanded: true }) { - var row = treeRows[index]; - if (row is { CanExpand: true, IsExpanded: true }) - { - state.IlTreeExpansionState[row.ExpansionKey] = false; - state.App.Invalidate(); - } + state.IlTreeExpansionState[row.ExpansionKey] = false; + state.App.Invalidate(); } }) ], diff --git a/src/Dotsider/Views/IlTreeList.cs b/src/Dotsider/Views/IlTreeList.cs index 0ec2530a..d974bbb6 100644 --- a/src/Dotsider/Views/IlTreeList.cs +++ b/src/Dotsider/Views/IlTreeList.cs @@ -34,10 +34,14 @@ internal static class IlTreeList /// /// The flattened tree rows. /// Pre-formatted display strings (one per row). + /// Returns the current flattened tree rows for input + /// dispatch. This can differ from when a queued key + /// arrives after search, expansion, or mode state changed but before the + /// next rendered tree replaces this binding closure. /// The shared application state. Read live by every binding so /// coalesced input batches operate on post-mutation selection. /// Invoked by keyboard navigation and row clicks - /// after the new index is computed. The caller is expected to assign + /// after the new row is computed. The caller is expected to assign /// state.IlFocusedTreeKey directly (no ), /// because the keyboard path manages scroll-into-view itself and must not arm /// . @@ -51,11 +55,12 @@ internal static class IlTreeList internal static Hex1bWidget Build( IReadOnlyList rows, IReadOnlyList formattedRows, + Func> getRows, DotsiderState state, - Action? selectionChanged, - Action? itemActivated, - Action? expandRow, - Action? collapseRow) + Action? selectionChanged, + Action? itemActivated, + Action? expandRow, + Action? collapseRow) { var selectedIndex = ResolveEffectiveIndex(rows, state.IlFocusedTreeKey); @@ -122,79 +127,86 @@ internal static Hex1bWidget Build( bindings.Remove(ScrollPanelWidget.MouseScrollDownAction); bindings.Key(Hex1bKey.UpArrow).Action(e => - MoveSelection(e, rows, state, selectionChanged, -1), "Move up"); + MoveSelection(e, getRows(), state, selectionChanged, -1), "Move up"); bindings.Key(Hex1bKey.DownArrow).Action(e => - MoveSelection(e, rows, state, selectionChanged, +1), "Move down"); + MoveSelection(e, getRows(), state, selectionChanged, +1), "Move down"); bindings.Key(Hex1bKey.Home).Action(e => - SetSelection(e, rows, state, selectionChanged, 0), "Top"); + SetSelection(e, getRows(), state, selectionChanged, 0), "Top"); bindings.Key(Hex1bKey.End).Action(e => - SetSelection(e, rows, state, selectionChanged, rows.Count - 1), "Bottom"); + { + var currentRows = getRows(); + SetSelection(e, currentRows, state, selectionChanged, currentRows.Count - 1); + }, "Bottom"); bindings.Key(Hex1bKey.PageUp).Action(e => { if (e.FocusedNode is not ScrollPanelNode sp) return; + var currentRows = getRows(); var step = Math.Max(1, sp.ViewportSize - 1); - MoveSelection(e, rows, state, selectionChanged, -step); + MoveSelection(e, currentRows, state, selectionChanged, -step); }, "Page up"); bindings.Key(Hex1bKey.PageDown).Action(e => { if (e.FocusedNode is not ScrollPanelNode sp) return; + var currentRows = getRows(); var step = Math.Max(1, sp.ViewportSize - 1); - MoveSelection(e, rows, state, selectionChanged, +step); + MoveSelection(e, currentRows, state, selectionChanged, +step); }, "Page down"); bindings.Key(Hex1bKey.Enter).Action(_ => - ActivateCurrent(rows, state, itemActivated), "Activate"); + ActivateCurrent(getRows(), state, itemActivated), "Activate"); bindings.Key(Hex1bKey.Spacebar).Action(_ => - ActivateCurrent(rows, state, itemActivated), "Activate"); + ActivateCurrent(getRows(), state, itemActivated), "Activate"); bindings.Key(Hex1bKey.LeftArrow).Action(e => - HandleLeft(e, rows, state, selectionChanged, collapseRow), "Collapse / parent"); + HandleLeft(e, getRows(), state, selectionChanged, collapseRow), "Collapse / parent"); bindings.Key(Hex1bKey.RightArrow).Action(e => - HandleRight(e, rows, state, selectionChanged, expandRow), "Expand / child"); + HandleRight(e, getRows(), state, selectionChanged, expandRow), "Expand / child"); // Wheel scrolls the viewport only — the selection stays put, even // offscreen, mirroring the old panel's decoupled wheel behavior. bindings.Mouse(MouseButton.ScrollUp).Action(_ => - ScrollViewport(state, rows.Count, -3), "Scroll up"); + ScrollViewport(state, getRows().Count, -3), "Scroll up"); bindings.Mouse(MouseButton.ScrollDown).Action(_ => - ScrollViewport(state, rows.Count, +3), "Scroll down"); + ScrollViewport(state, getRows().Count, +3), "Scroll down"); // Gutter presses: thumb drag or track-click paging. Row-area presses // return an empty handler, which Hex1b treats as a rejected drag and // falls through to the Left-click row selection below. bindings.Drag(MouseButton.Left).Action((localX, localY) => { + var currentRows = getRows(); var sp = state.IlScrollPanelNode; - if (sp is null || rows.Count <= sp.ViewportSize) return new DragHandler(); + if (sp is null || currentRows.Count <= sp.ViewportSize) return new DragHandler(); if (localX < sp.Bounds.Width - 1) return new DragHandler(); - return IlTreeScrollbar.HandleDrag(state, rows.Count, sp.ViewportSize, localY); + return IlTreeScrollbar.HandleDrag(state, currentRows.Count, sp.ViewportSize, localY); }, "Drag scrollbar"); bindings.Mouse(MouseButton.Left).Action(e => { + var currentRows = getRows(); if (e.FocusedNode is not ScrollPanelNode sp) return; - if (rows.Count == 0) return; + if (currentRows.Count == 0) return; // The rightmost column is the scrollbar gutter only when the tree // actually overflows; when content fits, that column is normal row // area and a click there must still select the row. var localX = e.MouseX - sp.Bounds.X; if (localX < 0 || localX >= sp.Bounds.Width) return; - if (rows.Count > sp.ViewportSize && localX >= sp.Bounds.Width - 1) return; + if (currentRows.Count > sp.ViewportSize && localX >= sp.Bounds.Width - 1) return; var rowIndex = (e.MouseY - sp.Bounds.Y) + state.IlTreeScrollOffset; - if (rowIndex < 0 || rowIndex >= rows.Count) return; + if (rowIndex < 0 || rowIndex >= currentRows.Count) return; - selectionChanged?.Invoke(rowIndex); - itemActivated?.Invoke(rowIndex); + selectionChanged?.Invoke(currentRows[rowIndex]); + itemActivated?.Invoke(currentRows[rowIndex]); }, "Select row"); }) .Fill(); @@ -261,7 +273,7 @@ private static void MoveSelection( InputBindingActionContext e, IReadOnlyList rows, DotsiderState state, - Action? selectionChanged, + Action? selectionChanged, int delta) { if (e.FocusedNode is not ScrollPanelNode sp) return; @@ -280,7 +292,7 @@ private static void MoveSelection( return; } - selectionChanged?.Invoke(newIndex); + selectionChanged?.Invoke(rows[newIndex]); IlInspectorView.EnsureSelectionVisible(state, sp, newIndex, rows.Count); state.App.Invalidate(); } @@ -289,7 +301,7 @@ private static void SetSelection( InputBindingActionContext e, IReadOnlyList rows, DotsiderState state, - Action? selectionChanged, + Action? selectionChanged, int target) { if (e.FocusedNode is not ScrollPanelNode sp) return; @@ -305,7 +317,7 @@ private static void SetSelection( return; } - selectionChanged?.Invoke(newIndex); + selectionChanged?.Invoke(rows[newIndex]); IlInspectorView.EnsureSelectionVisible(state, sp, newIndex, rows.Count); state.App.Invalidate(); } @@ -313,20 +325,20 @@ private static void SetSelection( private static void ActivateCurrent( IReadOnlyList rows, DotsiderState state, - Action? itemActivated) + Action? itemActivated) { if (rows.Count == 0) return; var idx = ResolveEffectiveIndex(rows, state.IlFocusedTreeKey); if (idx >= 0 && idx < rows.Count) - itemActivated?.Invoke(idx); + itemActivated?.Invoke(rows[idx]); } private static void HandleLeft( InputBindingActionContext e, IReadOnlyList rows, DotsiderState state, - Action? selectionChanged, - Action? collapseRow) + Action? selectionChanged, + Action? collapseRow) { if (e.FocusedNode is not ScrollPanelNode sp) return; if (rows.Count == 0) return; @@ -336,7 +348,7 @@ private static void HandleLeft( var row = rows[idx]; if (row is { CanExpand: true, IsExpanded: true }) { - collapseRow?.Invoke(idx); + collapseRow?.Invoke(row); return; } @@ -346,7 +358,7 @@ private static void HandleLeft( { if (rows[i].Depth < row.Depth) { - selectionChanged?.Invoke(i); + selectionChanged?.Invoke(rows[i]); IlInspectorView.EnsureSelectionVisible(state, sp, i, rows.Count); state.App.Invalidate(); return; @@ -358,8 +370,8 @@ private static void HandleRight( InputBindingActionContext e, IReadOnlyList rows, DotsiderState state, - Action? selectionChanged, - Action? expandRow) + Action? selectionChanged, + Action? expandRow) { if (e.FocusedNode is not ScrollPanelNode sp) return; if (rows.Count == 0) return; @@ -369,7 +381,7 @@ private static void HandleRight( var row = rows[idx]; if (row is { CanExpand: true, IsExpanded: false }) { - expandRow?.Invoke(idx); + expandRow?.Invoke(row); return; } @@ -377,7 +389,7 @@ private static void HandleRight( if (row.IsExpanded && idx + 1 < rows.Count && rows[idx + 1].Depth == row.Depth + 1) { var childIdx = idx + 1; - selectionChanged?.Invoke(childIdx); + selectionChanged?.Invoke(rows[childIdx]); IlInspectorView.EnsureSelectionVisible(state, sp, childIdx, rows.Count); state.App.Invalidate(); } diff --git a/src/Dotsider/Views/StringsView.cs b/src/Dotsider/Views/StringsView.cs index 54759c1e..51b3f0b8 100644 --- a/src/Dotsider/Views/StringsView.cs +++ b/src/Dotsider/Views/StringsView.cs @@ -95,6 +95,12 @@ [.. SourceTabs.Select((name, i) => ) .OnSelectionChanged(e => { + if (state.StringsDetailContent is not null) + { + state.App.Invalidate(); + return; + } + state.StringsSourceTab = e.SelectedIndex; search.Reset(); state.StringsFocusedKey = null; @@ -144,8 +150,9 @@ [.. SourceTabs.Select((name, i) => { var isSearchEditing = search.IsActive && !search.IsConfirmed; - // Left/Right arrows to switch sub-tabs (suppressed during search editing) - if (!isSearchEditing) + // Left/Right arrows to switch sub-tabs. Detail popups behave modally, + // so their editor/click-away surface owns navigation while open. + if (!isSearchEditing && state.StringsDetailContent is null) { if (state.App.FocusedNode is not EditorNode) { diff --git a/src/Dotsider/Views/TextObjectHelper.cs b/src/Dotsider/Views/TextObjectHelper.cs index e55e09a0..a09a0b32 100644 --- a/src/Dotsider/Views/TextObjectHelper.cs +++ b/src/Dotsider/Views/TextObjectHelper.cs @@ -167,20 +167,34 @@ public static void ConfigureReadOnlyEditorBindings( invalidate(); }, ""); - // --- Triple-click override: select line content only (no trailing newline) --- - // The default EditorWidget triple-click uses SelectLineAt which positions the - // cursor at the start of the NEXT line (exclusive end convention). PerformEditorYank - // adds +1 to cursor.Position (inclusive/neovim convention for iw/iW). These two - // conventions clash, causing yank to grab the newline plus the first character of - // the next line. Fix: replace the default handler with one that positions the - // cursor on the last visible character of the line (inclusive end). + // --- Double/triple-click overrides --- + // Hex1b click-count matching allows a double-click binding to match triple-clicks, + // so register both handlers here with the line-selection handler first. + bindings.Remove(EditorWidget.DoubleClick); bindings.Remove(EditorWidget.TripleClick); + bindings.Mouse(MouseButton.Left).TripleClick().Action(_ => { + ResetToIdle(); SelectLine(thisEditorState); invalidate(); }, "Triple-click to select line"); + bindings.Mouse(MouseButton.Left).DoubleClick().Action(_ => + { + ResetToIdle(); + thisEditorState.SelectWordAt(thisEditorState.Cursor.Position); + invalidate(); + }, "Double-click to select word"); + + // --- Triple-click behavior: select line content only (no trailing newline) --- + // The default EditorWidget triple-click uses SelectLineAt which positions the + // cursor at the start of the NEXT line (exclusive end convention). PerformEditorYank + // adds +1 to cursor.Position (inclusive/neovim convention for iw/iW). These two + // conventions clash, causing yank to grab the newline plus the first character of + // the next line. Fix: replace the default handler with one that positions the + // cursor on the last visible character of the line (inclusive end). + // --- Shift+V: visual line select (vim V) --- bindings.Shift().Key(Hex1bKey.V).Action(_ => { @@ -265,8 +279,6 @@ public static void ConfigureReadOnlyEditorBindings( // --- Cancellation: mouse bindings --- bindings.Mouse(MouseButton.Left).Action(() => ResetToIdle(), ""); bindings.Mouse(MouseButton.Left).Ctrl().Action(() => ResetToIdle(), ""); - bindings.Mouse(MouseButton.Left).DoubleClick().Action(() => ResetToIdle(), ""); - bindings.Mouse(MouseButton.Left).TripleClick().Action(() => ResetToIdle(), ""); bindings.Drag(MouseButton.Left).Action((_, _) => { ResetToIdle(); diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 00000000..16e2e44f --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,11 @@ + + + + + All + + + + + + diff --git a/tests/Dotsider.Mcp.Tests/AssemblyToolsTests.cs b/tests/Dotsider.Mcp.Tests/AssemblyToolsTests.cs index da2820b0..aabde407 100644 --- a/tests/Dotsider.Mcp.Tests/AssemblyToolsTests.cs +++ b/tests/Dotsider.Mcp.Tests/AssemblyToolsTests.cs @@ -8,13 +8,15 @@ namespace Dotsider.Mcp.Tests; /// /// Creates the tests using the shared sample assembly fixture. /// -[Collection("SampleAssemblies")] -public class AssemblyToolsTests(SampleAssemblyFixture samples) : McpServerTestBase +[TestClass] +public class AssemblyToolsTests : McpServerTestBase { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// Verifies get_assembly_info returns populated metadata for a simple console executable. /// - [Fact] + [TestMethod] public async Task GetAssemblyInfo_HelloWorld_ReturnsAssemblyMetadata() { await StartServerAsync(); @@ -22,22 +24,22 @@ public async Task GetAssemblyInfo_HelloWorld_ReturnsAssemblyMetadata() var result = await client.CallToolAsync( "get_assembly_info", - new Dictionary { ["assemblyPath"] = samples.HelloWorldDll }, + new Dictionary { ["assemblyPath"] = Samples.HelloWorldDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.Equal("HelloWorld", json.GetProperty("assemblyName").GetString()); - Assert.True(json.GetProperty("hasMetadata").GetBoolean()); - Assert.True(json.GetProperty("typeCount").GetInt32() > 0); - Assert.True(json.GetProperty("methodCount").GetInt32() > 0); + Assert.AreEqual("HelloWorld", json.GetProperty("assemblyName").GetString()); + Assert.IsTrue(json.GetProperty("hasMetadata").GetBoolean()); + Assert.IsGreaterThan(0, json.GetProperty("typeCount").GetInt32()); + Assert.IsGreaterThan(0, json.GetProperty("methodCount").GetInt32()); } /// /// Confirms get_assembly_info surfaces version data and external references for a richer library. /// - [Fact] + [TestMethod] public async Task GetAssemblyInfo_RichLibrary_IncludesVersion() { await StartServerAsync(); @@ -45,20 +47,20 @@ public async Task GetAssemblyInfo_RichLibrary_IncludesVersion() var result = await client.CallToolAsync( "get_assembly_info", - new Dictionary { ["assemblyPath"] = samples.RichLibraryDll }, + new Dictionary { ["assemblyPath"] = Samples.RichLibraryDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.Equal("RichLibrary", json.GetProperty("assemblyName").GetString()); - Assert.True(json.GetProperty("assemblyRefCount").GetInt32() > 0); + Assert.AreEqual("RichLibrary", json.GetProperty("assemblyName").GetString()); + Assert.IsGreaterThan(0, json.GetProperty("assemblyRefCount").GetInt32()); } /// /// Invoking get_assembly_info without required arguments yields a descriptive error payload. /// - [Fact] + [TestMethod] public async Task GetAssemblyInfo_NoParams_ReturnsError() { await StartServerAsync(); @@ -70,14 +72,14 @@ public async Task GetAssemblyInfo_NoParams_ReturnsError() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains("Error", text); } /// /// list_types enumerates defined types from a basic assembly. /// - [Fact] + [TestMethod] public async Task ListTypes_HelloWorld_ReturnsTypes() { await StartServerAsync(); @@ -85,19 +87,19 @@ public async Task ListTypes_HelloWorld_ReturnsTypes() var result = await client.CallToolAsync( "list_types", - new Dictionary { ["assemblyPath"] = samples.HelloWorldDll }, + new Dictionary { ["assemblyPath"] = Samples.HelloWorldDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var types = JsonSerializer.Deserialize(text); - Assert.True(types.GetArrayLength() > 0); + Assert.IsGreaterThan(0, types.GetArrayLength()); } /// /// A query filter narrows list_types output to name-matching results only. /// - [Fact] + [TestMethod] public async Task ListTypes_WithQuery_FiltersResults() { await StartServerAsync(); @@ -107,17 +109,17 @@ public async Task ListTypes_WithQuery_FiltersResults() "list_types", new Dictionary { - ["assemblyPath"] = samples.RichLibraryDll, + ["assemblyPath"] = Samples.RichLibraryDll, ["query"] = "UserService" }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var types = JsonSerializer.Deserialize(text); foreach (var type in types.EnumerateArray()) { - Assert.Contains("UserService", type.GetProperty("fullName").GetString(), + Assert.Contains("UserService", type.GetProperty("fullName").GetString()!, StringComparison.OrdinalIgnoreCase); } } @@ -125,7 +127,7 @@ public async Task ListTypes_WithQuery_FiltersResults() /// /// maxResults caps list_types output to protect clients from oversized payloads. /// - [Fact] + [TestMethod] public async Task ListTypes_WithMaxResults_LimitsOutput() { await StartServerAsync(); @@ -135,21 +137,21 @@ public async Task ListTypes_WithMaxResults_LimitsOutput() "list_types", new Dictionary { - ["assemblyPath"] = samples.RichLibraryDll, + ["assemblyPath"] = Samples.RichLibraryDll, ["maxResults"] = 3 }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var types = JsonSerializer.Deserialize(text); - Assert.True(types.GetArrayLength() <= 3); + Assert.IsLessThanOrEqualTo(3, types.GetArrayLength()); } /// /// list_methods returns defined methods for a trivial assembly. /// - [Fact] + [TestMethod] public async Task ListMethods_HelloWorld_ReturnsMethods() { await StartServerAsync(); @@ -157,19 +159,19 @@ public async Task ListMethods_HelloWorld_ReturnsMethods() var result = await client.CallToolAsync( "list_methods", - new Dictionary { ["assemblyPath"] = samples.HelloWorldDll }, + new Dictionary { ["assemblyPath"] = Samples.HelloWorldDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var methods = JsonSerializer.Deserialize(text); - Assert.True(methods.GetArrayLength() > 0); + Assert.IsGreaterThan(0, methods.GetArrayLength()); } /// /// typeName filter restricts list_methods to methods of the specified declaring type. /// - [Fact] + [TestMethod] public async Task ListMethods_FilterByTypeName_ReturnsFilteredMethods() { await StartServerAsync(); @@ -179,18 +181,18 @@ public async Task ListMethods_FilterByTypeName_ReturnsFilteredMethods() "list_methods", new Dictionary { - ["assemblyPath"] = samples.RichLibraryDll, + ["assemblyPath"] = Samples.RichLibraryDll, ["typeName"] = "UserService" }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var methods = JsonSerializer.Deserialize(text); - Assert.True(methods.GetArrayLength() > 0); + Assert.IsGreaterThan(0, methods.GetArrayLength()); foreach (var method in methods.EnumerateArray()) { - Assert.Contains("UserService", method.GetProperty("declaringType").GetString(), + Assert.Contains("UserService", method.GetProperty("declaringType").GetString()!, StringComparison.OrdinalIgnoreCase); } } @@ -198,7 +200,7 @@ public async Task ListMethods_FilterByTypeName_ReturnsFilteredMethods() /// /// find_members returns a grouped payload of types and methods matching the query. /// - [Fact] + [TestMethod] public async Task FindMembers_SearchQuery_ReturnsMatchingMembers() { await StartServerAsync(); @@ -208,21 +210,21 @@ public async Task FindMembers_SearchQuery_ReturnsMatchingMembers() "find_members", new Dictionary { - ["assemblyPath"] = samples.RichLibraryDll, + ["assemblyPath"] = Samples.RichLibraryDll, ["query"] = "User" }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.True(json.TryGetProperty("types", out _) || json.TryGetProperty("methods", out _)); + Assert.IsTrue(json.TryGetProperty("types", out _) || json.TryGetProperty("methods", out _)); } /// /// A missing file surfaces as an IsError result with a clear not-found message. /// - [Fact] + [TestMethod] public async Task GetAssemblyInfo_NonexistentFile_ReturnsFileNotFoundError() { await StartServerAsync(); @@ -233,9 +235,9 @@ public async Task GetAssemblyInfo_NonexistentFile_ReturnsFileNotFoundError() new Dictionary { ["assemblyPath"] = "/nonexistent/path.dll" }, cancellationToken: TestCancellationToken); - Assert.True(result.IsError); + Assert.IsTrue(result.IsError); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains("File not found", text); Assert.Contains("/nonexistent/path.dll", text); } @@ -243,7 +245,7 @@ public async Task GetAssemblyInfo_NonexistentFile_ReturnsFileNotFoundError() /// /// An effectively empty assembly still returns a valid JSON array from list_types. /// - [Fact] + [TestMethod] public async Task ListTypes_EmptyLib_ReturnsMinimalTypes() { await StartServerAsync(); @@ -251,22 +253,23 @@ public async Task ListTypes_EmptyLib_ReturnsMinimalTypes() var result = await client.CallToolAsync( "list_types", - new Dictionary { ["assemblyPath"] = samples.EmptyLibDll }, + new Dictionary { ["assemblyPath"] = Samples.EmptyLibDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var types = JsonSerializer.Deserialize(text); - Assert.Equal(JsonValueKind.Array, types.ValueKind); + Assert.AreEqual(JsonValueKind.Array, types.ValueKind); } /// /// list_types unwraps Webcil browser app assemblies and returns their managed type definitions. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ListTypes_WebcilWasm_ReturnsManagedTypes() { - Assert.SkipWhen(samples.WasmConsoleWebcilWasm is null, + TestSkip.When(Samples.WasmConsoleWebcilWasm is null, "browser-wasm publish did not produce the Webcil app assembly on this leg."); await StartServerAsync(); @@ -274,70 +277,73 @@ public async Task ListTypes_WebcilWasm_ReturnsManagedTypes() var result = await client.CallToolAsync( "list_types", - new Dictionary { ["assemblyPath"] = samples.WasmConsoleWebcilWasm }, + new Dictionary { ["assemblyPath"] = Samples.WasmConsoleWebcilWasm }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var types = JsonSerializer.Deserialize(text); - Assert.Contains(types.EnumerateArray(), static type => - type.GetProperty("fullName").GetString() == "WasmCalculator"); + Assert.Contains(static type => + type.GetProperty("fullName").GetString() == "WasmCalculator", types.EnumerateArray()); } /// /// get_assembly_info exposes displayName, bundle flags, and preferred runtime pack. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetAssemblyInfo_IncludesNewProperties() { await StartServerAsync(); await using var client = await CreateClientAsync(); var result = await client.CallToolAsync("get_assembly_info", - new Dictionary { ["assemblyPath"] = samples.RichLibraryDll }, + new Dictionary { ["assemblyPath"] = Samples.RichLibraryDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.NotNull(json.GetProperty("displayName").GetString()); - Assert.False(json.GetProperty("isBundleBacked").GetBoolean()); - Assert.True(json.GetProperty("canSaveInPlace").GetBoolean()); - Assert.Equal("Microsoft.NETCore.App", json.GetProperty("preferredRuntimePack").GetString()); + Assert.IsNotNull(json.GetProperty("displayName").GetString()); + Assert.IsFalse(json.GetProperty("isBundleBacked").GetBoolean()); + Assert.IsTrue(json.GetProperty("canSaveInPlace").GetBoolean()); + Assert.AreEqual("Microsoft.NETCore.App", json.GetProperty("preferredRuntimePack").GetString()); } /// /// get_assembly_info reports a Native AOT executable's binary kind and /// ReadyToRun header facts. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetAssemblyInfo_NativeAot_ReportsBinaryKindAndRtr() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); await StartServerAsync(); await using var client = await CreateClientAsync(); var result = await client.CallToolAsync("get_assembly_info", - new Dictionary { ["assemblyPath"] = samples.NativeAotConsoleExe }, + new Dictionary { ["assemblyPath"] = Samples.NativeAotConsoleExe }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.Equal("nativeAot", json.GetProperty("binaryKind").GetString()); + Assert.AreEqual("nativeAot", json.GetProperty("binaryKind").GetString()); var aotInfo = json.GetProperty("nativeAotInfo"); - Assert.True(aotInfo.GetProperty("majorVersion").GetInt32() >= 1); - Assert.True(aotInfo.GetProperty("sectionCount").GetInt32() >= 1); - Assert.False(json.GetProperty("hasMetadata").GetBoolean()); + Assert.IsGreaterThanOrEqualTo(1, aotInfo.GetProperty("majorVersion").GetInt32()); + Assert.IsGreaterThanOrEqualTo(1, aotInfo.GetProperty("sectionCount").GetInt32()); + Assert.IsFalse(json.GetProperty("hasMetadata").GetBoolean()); } /// /// get_assembly_info reports a raw dotnet.native.wasm module as WebAssembly and includes /// function, code, data, and symbol-map facts from the SDK-produced module. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetAssemblyInfo_Wasm_ReportsModuleFacts() { var wasmPath = GetWasmNativePath(); @@ -350,155 +356,161 @@ public async Task GetAssemblyInfo_Wasm_ReportsModuleFacts() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.Equal("wasm", json.GetProperty("binaryKind").GetString()); - Assert.Equal("Wasm32", json.GetProperty("architecture").GetString()); - Assert.False(json.GetProperty("hasMetadata").GetBoolean()); + Assert.AreEqual("wasm", json.GetProperty("binaryKind").GetString()); + Assert.AreEqual("Wasm32", json.GetProperty("architecture").GetString()); + Assert.IsFalse(json.GetProperty("hasMetadata").GetBoolean()); var wasm = json.GetProperty("wasm"); - Assert.True(wasm.GetProperty("definedFunctionCount").GetInt32() > 0); - Assert.True(wasm.GetProperty("importedFunctionCount").GetInt32() > 0); - Assert.True(wasm.GetProperty("codeSize").GetInt64() > 0); - Assert.True(wasm.GetProperty("dataSize").GetInt64() > 0); - Assert.Equal("Loaded", wasm.GetProperty("symbolMapStatus").GetString()); + Assert.IsGreaterThan(0, wasm.GetProperty("definedFunctionCount").GetInt32()); + Assert.IsGreaterThan(0, wasm.GetProperty("importedFunctionCount").GetInt32()); + Assert.IsGreaterThan(0, wasm.GetProperty("codeSize").GetInt64()); + Assert.IsGreaterThan(0, wasm.GetProperty("dataSize").GetInt64()); + Assert.AreEqual("Loaded", wasm.GetProperty("symbolMapStatus").GetString()); } /// /// get_assembly_info reports a Webcil-wrapped browser app assembly as normal managed metadata /// with Webcil payload facts attached, not as the raw runtime WebAssembly module. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetAssemblyInfo_WebcilWasm_ReportsManagedMetadata() { - Assert.SkipWhen(samples.WasmConsoleWebcilWasm is null, + TestSkip.When(Samples.WasmConsoleWebcilWasm is null, "browser-wasm publish did not produce the Webcil app assembly on this leg."); await StartServerAsync(); await using var client = await CreateClientAsync(); var result = await client.CallToolAsync("get_assembly_info", - new Dictionary { ["assemblyPath"] = samples.WasmConsoleWebcilWasm }, + new Dictionary { ["assemblyPath"] = Samples.WasmConsoleWebcilWasm }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.Equal("managed", json.GetProperty("binaryKind").GetString()); - Assert.Equal("Wasm32", json.GetProperty("architecture").GetString()); - Assert.True(json.GetProperty("hasMetadata").GetBoolean()); - Assert.Equal("WasmConsole", json.GetProperty("assemblyName").GetString()); - Assert.False(json.TryGetProperty("wasm", out _)); + Assert.AreEqual("managed", json.GetProperty("binaryKind").GetString()); + Assert.AreEqual("Wasm32", json.GetProperty("architecture").GetString()); + Assert.IsTrue(json.GetProperty("hasMetadata").GetBoolean()); + Assert.AreEqual("WasmConsole", json.GetProperty("assemblyName").GetString()); + Assert.IsFalse(json.TryGetProperty("wasm", out _)); var webcil = json.GetProperty("webcil"); - Assert.True(webcil.GetProperty("isWasmWrapped").GetBoolean()); - Assert.True(webcil.GetProperty("sectionCount").GetInt32() > 0); - Assert.True(webcil.GetProperty("metadataSize").GetInt32() > 0); + Assert.IsTrue(webcil.GetProperty("isWasmWrapped").GetBoolean()); + Assert.IsGreaterThan(0, webcil.GetProperty("sectionCount").GetInt32()); + Assert.IsGreaterThan(0, webcil.GetProperty("metadataSize").GetInt32()); } /// /// get_assembly_info reports a managed assembly's binary kind as managed with /// no Native AOT info attached. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetAssemblyInfo_Managed_ReportsManagedKind() { await StartServerAsync(); await using var client = await CreateClientAsync(); var result = await client.CallToolAsync("get_assembly_info", - new Dictionary { ["assemblyPath"] = samples.RichLibraryDll }, + new Dictionary { ["assemblyPath"] = Samples.RichLibraryDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.Equal("managed", json.GetProperty("binaryKind").GetString()); - Assert.False(json.TryGetProperty("nativeAotInfo", out _)); + Assert.AreEqual("managed", json.GetProperty("binaryKind").GetString()); + Assert.IsFalse(json.TryGetProperty("nativeAotInfo", out _)); } /// /// A self-contained apphost is reported as bundle-backed with an in-bundle display name. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetAssemblyInfo_BundleBacked_ShowsBundleInfo() { - Assert.NotNull(samples.SelfContainedConsoleExe); + Assert.IsNotNull(Samples.SelfContainedConsoleExe); await StartServerAsync(); await using var client = await CreateClientAsync(); var result = await client.CallToolAsync("get_assembly_info", - new Dictionary { ["assemblyPath"] = samples.SelfContainedConsoleExe }, + new Dictionary { ["assemblyPath"] = Samples.SelfContainedConsoleExe }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.True(json.GetProperty("isBundleBacked").GetBoolean()); - Assert.Equal("SelfContainedConsole.dll", json.GetProperty("displayName").GetString()); - Assert.False(json.GetProperty("canSaveInPlace").GetBoolean()); + Assert.IsTrue(json.GetProperty("isBundleBacked").GetBoolean()); + Assert.AreEqual("SelfContainedConsole.dll", json.GetProperty("displayName").GetString()); + Assert.IsFalse(json.GetProperty("canSaveInPlace").GetBoolean()); } /// /// ASP.NET Core apps report Microsoft.AspNetCore.App as their preferred runtime pack. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetAssemblyInfo_AspNetCore_PreferredPack() { await StartServerAsync(); await using var client = await CreateClientAsync(); var result = await client.CallToolAsync("get_assembly_info", - new Dictionary { ["assemblyPath"] = samples.MinimalApiDll }, + new Dictionary { ["assemblyPath"] = Samples.MinimalApiDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.Equal("Microsoft.AspNetCore.App", json.GetProperty("preferredRuntimePack").GetString()); + Assert.AreEqual("Microsoft.AspNetCore.App", json.GetProperty("preferredRuntimePack").GetString()); } /// /// get_assembly_info reports Native AOT section, recovered-type, and frozen-string counts. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetAssemblyInfo_NativeAot_ReportsAotCounts() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); await StartServerAsync(); await using var client = await CreateClientAsync(); var result = await client.CallToolAsync("get_assembly_info", - new Dictionary { ["assemblyPath"] = samples.NativeAotConsoleExe }, + new Dictionary { ["assemblyPath"] = Samples.NativeAotConsoleExe }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.True(json.GetProperty("readyToRunSectionCount").GetInt32() > 0); - Assert.True(json.GetProperty("recoveredTypeCount").GetInt32() > 0); - Assert.True(json.TryGetProperty("frozenStringCount", out _)); + Assert.IsGreaterThan(0, json.GetProperty("readyToRunSectionCount").GetInt32()); + Assert.IsGreaterThan(0, json.GetProperty("recoveredTypeCount").GetInt32()); + Assert.IsTrue(json.TryGetProperty("frozenStringCount", out _)); } /// /// list_types falls back to the types recovered from a Native AOT binary's metadata. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ListTypes_NativeAot_ReturnsRecoveredTypes() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); await StartServerAsync(); await using var client = await CreateClientAsync(); var result = await client.CallToolAsync("list_types", - new Dictionary { ["assemblyPath"] = samples.NativeAotConsoleExe }, + new Dictionary { ["assemblyPath"] = Samples.NativeAotConsoleExe }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.Equal(JsonValueKind.Array, json.ValueKind); - Assert.True(json.GetArrayLength() > 0); + Assert.AreEqual(JsonValueKind.Array, json.ValueKind); + Assert.IsGreaterThan(0, json.GetArrayLength()); var names = json.EnumerateArray().Select(e => e.GetProperty("fullName").GetString()).ToList(); Assert.Contains("Program", names); } @@ -506,10 +518,11 @@ public async Task ListTypes_NativeAot_ReturnsRecoveredTypes() /// /// list_methods falls back to recovered Native AOT method names when ECMA metadata is absent. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ListMethods_NativeAot_ReturnsRecoveredMethods() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); await StartServerAsync(); await using var client = await CreateClientAsync(); @@ -517,27 +530,28 @@ public async Task ListMethods_NativeAot_ReturnsRecoveredMethods() var result = await client.CallToolAsync("list_methods", new Dictionary { - ["assemblyPath"] = samples.NativeAotConsoleExe, + ["assemblyPath"] = Samples.NativeAotConsoleExe, ["typeName"] = "Program" }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.Equal(JsonValueKind.Array, json.ValueKind); - Assert.True(json.GetArrayLength() > 0); - Assert.All(json.EnumerateArray(), e => - Assert.Equal("RecoveredNativeAot", e.GetProperty("source").GetString())); + Assert.AreEqual(JsonValueKind.Array, json.ValueKind); + Assert.IsGreaterThan(0, json.GetArrayLength()); + TestAssert.All(json.EnumerateArray(), e => + Assert.AreEqual("RecoveredNativeAot", e.GetProperty("source").GetString())); } /// /// find_members searches recovered Native AOT types and methods instead of metadata-only tables. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task FindMembers_NativeAot_SearchesRecoveredInventory() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); await StartServerAsync(); await using var client = await CreateClientAsync(); @@ -545,24 +559,24 @@ public async Task FindMembers_NativeAot_SearchesRecoveredInventory() var result = await client.CallToolAsync("find_members", new Dictionary { - ["assemblyPath"] = samples.NativeAotConsoleExe, + ["assemblyPath"] = Samples.NativeAotConsoleExe, ["query"] = "Program" }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.True(json.GetProperty("types").GetArrayLength() > 0 + Assert.IsTrue(json.GetProperty("types").GetArrayLength() > 0 || json.GetProperty("methods").GetArrayLength() > 0); - Assert.Equal(0, json.GetProperty("memberRefs").GetArrayLength()); + Assert.AreEqual(0, json.GetProperty("memberRefs").GetArrayLength()); } - private string GetWasmNativePath() + private static string GetWasmNativePath() { - Assert.SkipWhen(samples.WasmConsoleNativeWasm is null && samples.ReadyToRunConsoleWasmNativeWasm is null, + TestSkip.When(Samples.WasmConsoleNativeWasm is null && Samples.ReadyToRunConsoleWasmNativeWasm is null, "browser-wasm publish did not run on this leg."); - return samples.WasmConsoleNativeWasm ?? samples.ReadyToRunConsoleWasmNativeWasm!; + return Samples.WasmConsoleNativeWasm ?? Samples.ReadyToRunConsoleWasmNativeWasm!; } } diff --git a/tests/Dotsider.Mcp.Tests/BundleToolsTests.cs b/tests/Dotsider.Mcp.Tests/BundleToolsTests.cs index 76003cb6..91165cae 100644 --- a/tests/Dotsider.Mcp.Tests/BundleToolsTests.cs +++ b/tests/Dotsider.Mcp.Tests/BundleToolsTests.cs @@ -5,72 +5,78 @@ namespace Dotsider.Mcp.Tests; /// /// Tests for single-file bundle MCP tools: get_bundle_info and list_bundle_entries. /// -[Collection("SampleAssemblies")] -public class BundleToolsTests(SampleAssemblyFixture samples) : McpServerTestBase +[TestClass] +public class BundleToolsTests : McpServerTestBase { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// get_bundle_info on a self-contained apphost surfaces bundle flag and file count. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetBundleInfo_ReturnsBundleMetadata() { await StartServerAsync(); await using var client = await CreateClientAsync(); var result = await client.CallToolAsync("get_bundle_info", - new Dictionary { ["assemblyPath"] = samples.SelfContainedConsoleExe }, + new Dictionary { ["assemblyPath"] = Samples.SelfContainedConsoleExe }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.True(json.GetProperty("isBundle").GetBoolean()); - Assert.True(json.GetProperty("fileCount").GetInt32() > 0); + Assert.IsTrue(json.GetProperty("isBundle").GetBoolean()); + Assert.IsGreaterThan(0, json.GetProperty("fileCount").GetInt32()); } /// /// Non-bundle input returns false without false-positive bundle detection. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetBundleInfo_NonBundle_ReturnsFalse() { await StartServerAsync(); await using var client = await CreateClientAsync(); var result = await client.CallToolAsync("get_bundle_info", - new Dictionary { ["assemblyPath"] = samples.RichLibraryDll }, + new Dictionary { ["assemblyPath"] = Samples.RichLibraryDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.False(json.GetProperty("isBundle").GetBoolean()); + Assert.IsFalse(json.GetProperty("isBundle").GetBoolean()); } /// /// list_bundle_entries enumerates the files packed inside a single-file apphost. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ListBundleEntries_ReturnsEntries() { await StartServerAsync(); await using var client = await CreateClientAsync(); var result = await client.CallToolAsync("list_bundle_entries", - new Dictionary { ["assemblyPath"] = samples.SelfContainedConsoleExe }, + new Dictionary { ["assemblyPath"] = Samples.SelfContainedConsoleExe }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var entries = JsonSerializer.Deserialize(text); - Assert.Equal(JsonValueKind.Array, entries.ValueKind); - Assert.True(entries.GetArrayLength() > 0); + Assert.AreEqual(JsonValueKind.Array, entries.ValueKind); + Assert.IsGreaterThan(0, entries.GetArrayLength()); } /// /// Tool registry exposes both bundle tools by their expected identifiers. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ListTools_IncludesBundleTools() { await StartServerAsync(); diff --git a/tests/Dotsider.Mcp.Tests/CorrelationToolsTests.cs b/tests/Dotsider.Mcp.Tests/CorrelationToolsTests.cs index 6d57386d..4660e1c5 100644 --- a/tests/Dotsider.Mcp.Tests/CorrelationToolsTests.cs +++ b/tests/Dotsider.Mcp.Tests/CorrelationToolsTests.cs @@ -5,27 +5,30 @@ namespace Dotsider.Mcp.Tests; /// AOT fixture — the counts summary on get_assembly_info, a unique method resolved by name /// and by address, and the ambiguous-name error path. /// -[Collection("SampleAssemblies")] -public class CorrelationToolsTests(SampleAssemblyFixture samples) : McpServerTestBase +[TestClass] +public class CorrelationToolsTests : McpServerTestBase { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// get_assembly_info carries the cheap pre-ILC probe summary for a Native AOT binary. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetAssemblyInfo_NativeAot_CarriesPreIlcSummary() { - Assert.SkipWhen(samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); await StartServerAsync(); await using var client = await CreateClientAsync(); var result = await client.CallToolAsync( "get_assembly_info", - new Dictionary { ["assemblyPath"] = samples.NativeAotConsoleExe }, + new Dictionary { ["assemblyPath"] = Samples.NativeAotConsoleExe }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains("preIlc", text); Assert.Contains("hasAttachableCompanion", text); } @@ -33,10 +36,11 @@ public async Task GetAssemblyInfo_NativeAot_CarriesPreIlcSummary() /// /// correlate_method resolves a unique method by name and returns its IL and status. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task CorrelateMethod_ByName_ReturnsReport() { - Assert.SkipWhen(samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); await StartServerAsync(); await using var client = await CreateClientAsync(); @@ -46,13 +50,13 @@ public async Task CorrelateMethod_ByName_ReturnsReport() new Dictionary { ["methodOrAddress"] = "Greeter.Describe", - ["assemblyPath"] = samples.NativeAotConsoleExe + ["assemblyPath"] = Samples.NativeAotConsoleExe }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); - Assert.False(text.StartsWith("Error", StringComparison.Ordinal), "the tool reported an error"); + Assert.IsNotNull(text); + Assert.IsFalse(text.StartsWith("Error", StringComparison.Ordinal), "the tool reported an error"); Assert.Contains("\"method\"", text); Assert.Contains("Greeter::Describe", text); Assert.Contains("\"il\"", text); @@ -61,13 +65,14 @@ public async Task CorrelateMethod_ByName_ReturnsReport() /// /// correlate_method resolves by native address and returns the correlation-aware disassembly. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task CorrelateMethod_ByAddress_ReturnsNativeDisassembly() { - Assert.SkipWhen(samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); ulong? va = null; - using (var analyzer = new Dotsider.Core.Analysis.AssemblyAnalyzer(samples.NativeAotConsoleExe!)) + using (var analyzer = new Dotsider.Core.Analysis.AssemblyAnalyzer(Samples.NativeAotConsoleExe!)) { analyzer.AttachPreIlcCompanions(); var correlation = analyzer.ManagedNativeIndex?.Methods.FirstOrDefault(m => @@ -77,7 +82,7 @@ public async Task CorrelateMethod_ByAddress_ReturnsNativeDisassembly() va = correlation?.NativeSymbols[0].VirtualAddress; } - Assert.SkipWhen(va is null, "no exact correlation with a native symbol on this leg"); + TestSkip.When(va is null, "no exact correlation with a native symbol on this leg"); await StartServerAsync(); await using var client = await CreateClientAsync(); @@ -87,23 +92,24 @@ public async Task CorrelateMethod_ByAddress_ReturnsNativeDisassembly() new Dictionary { ["methodOrAddress"] = $"0x{va!.Value:x}", - ["assemblyPath"] = samples.NativeAotConsoleExe + ["assemblyPath"] = Samples.NativeAotConsoleExe }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); - Assert.False(text.StartsWith("Error", StringComparison.Ordinal), "the tool reported an error"); + Assert.IsNotNull(text); + Assert.IsFalse(text.StartsWith("Error", StringComparison.Ordinal), "the tool reported an error"); Assert.Contains("\"nativeDisassembly\"", text); } /// /// correlate_method surfaces an ambiguous name as an error listing every candidate. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task CorrelateMethod_AmbiguousName_ReturnsCandidateError() { - Assert.SkipWhen(samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); await StartServerAsync(); await using var client = await CreateClientAsync(); @@ -113,12 +119,12 @@ public async Task CorrelateMethod_AmbiguousName_ReturnsCandidateError() new Dictionary { ["methodOrAddress"] = "Greeter.Greet", - ["assemblyPath"] = samples.NativeAotConsoleExe + ["assemblyPath"] = Samples.NativeAotConsoleExe }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains("ambiguous", text); Assert.Contains("Greeter::Greet", text); } @@ -126,7 +132,8 @@ public async Task CorrelateMethod_AmbiguousName_ReturnsCandidateError() /// /// correlate_method reports the Native AOT requirement for a managed assembly. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task CorrelateMethod_Managed_ReturnsError() { await StartServerAsync(); @@ -137,12 +144,12 @@ public async Task CorrelateMethod_Managed_ReturnsError() new Dictionary { ["methodOrAddress"] = "Foo", - ["assemblyPath"] = samples.RichLibraryDll + ["assemblyPath"] = Samples.RichLibraryDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains("Error", text); Assert.Contains("Native AOT", text); } diff --git a/tests/Dotsider.Mcp.Tests/DependencyToolsTests.cs b/tests/Dotsider.Mcp.Tests/DependencyToolsTests.cs index ec48a3c4..4d5ecdbe 100644 --- a/tests/Dotsider.Mcp.Tests/DependencyToolsTests.cs +++ b/tests/Dotsider.Mcp.Tests/DependencyToolsTests.cs @@ -8,13 +8,15 @@ namespace Dotsider.Mcp.Tests; /// /// Creates the tests using the shared sample assembly fixture. /// -[Collection("SampleAssemblies")] -public class DependencyToolsTests(SampleAssemblyFixture samples) : McpServerTestBase +[TestClass] +public class DependencyToolsTests : McpServerTestBase { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// get_assembly_refs returns the AssemblyRef table entries for a real library. /// - [Fact] + [TestMethod] public async Task GetAssemblyRefs_RichLibrary_ReturnsDependencies() { await StartServerAsync(); @@ -22,13 +24,13 @@ public async Task GetAssemblyRefs_RichLibrary_ReturnsDependencies() var result = await client.CallToolAsync( "get_assembly_refs", - new Dictionary { ["assemblyPath"] = samples.RichLibraryDll }, + new Dictionary { ["assemblyPath"] = Samples.RichLibraryDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var refs = JsonSerializer.Deserialize(text); - Assert.True(refs.GetArrayLength() > 0); + Assert.IsGreaterThan(0, refs.GetArrayLength()); } /// @@ -37,7 +39,7 @@ public async Task GetAssemblyRefs_RichLibrary_ReturnsDependencies() /// id that is not the root — proving the tool emits transitive child-to-child edges, not /// only root-to-child edges. No internal navigation fields leak into the JSON payload. /// - [Fact] + [TestMethod] public async Task GetDependencyGraph_RichLibrary_ReturnsTransitiveGraphWithoutNavigationLeak() { await StartServerAsync(); @@ -45,41 +47,41 @@ public async Task GetDependencyGraph_RichLibrary_ReturnsTransitiveGraphWithoutNa var result = await client.CallToolAsync( "get_dependency_graph", - new Dictionary { ["assemblyPath"] = samples.RichLibraryDll }, + new Dictionary { ["assemblyPath"] = Samples.RichLibraryDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.True(json.TryGetProperty("nodes", out var nodes)); - Assert.True(nodes.GetArrayLength() > 0); - Assert.True(json.TryGetProperty("edges", out var edges)); + Assert.IsTrue(json.TryGetProperty("nodes", out var nodes)); + Assert.IsGreaterThan(0, nodes.GetArrayLength()); + Assert.IsTrue(json.TryGetProperty("edges", out var edges)); string? rootId = null; var anyDepthOverZero = false; foreach (var n in nodes.EnumerateArray()) { - Assert.True(n.TryGetProperty("id", out var id)); - Assert.False(string.IsNullOrEmpty(id.GetString())); + Assert.IsTrue(n.TryGetProperty("id", out var id)); + Assert.IsFalse(string.IsNullOrEmpty(id.GetString())); foreach (var leak in NavigationFieldsThatMustNotLeak) - Assert.False(n.TryGetProperty(leak, out _), $"node should not expose {leak}"); + Assert.IsFalse(n.TryGetProperty(leak, out _), $"node should not expose {leak}"); if (n.TryGetProperty("isRoot", out var isRoot) && isRoot.GetBoolean()) rootId = id.GetString(); if (n.TryGetProperty("depth", out var depth) && depth.GetInt32() > 0) anyDepthOverZero = true; } - Assert.True(anyDepthOverZero, "expected at least one node with depth > 0"); - Assert.NotNull(rootId); + Assert.IsTrue(anyDepthOverZero, "expected at least one node with depth > 0"); + Assert.IsNotNull(rootId); var anyNonRootSource = false; foreach (var e in edges.EnumerateArray()) { - Assert.True(e.TryGetProperty("sourceId", out var src)); - Assert.True(e.TryGetProperty("targetId", out _)); + Assert.IsTrue(e.TryGetProperty("sourceId", out var src)); + Assert.IsTrue(e.TryGetProperty("targetId", out _)); if (src.GetString() != rootId) anyNonRootSource = true; } - Assert.True(anyNonRootSource, "expected at least one edge whose source is not the root"); + Assert.IsTrue(anyNonRootSource, "expected at least one edge whose source is not the root"); } private static readonly string[] NavigationFieldsThatMustNotLeak = @@ -92,7 +94,7 @@ public async Task GetDependencyGraph_RichLibrary_ReturnsTransitiveGraphWithoutNa /// /// get_type_refs surfaces externally-referenced types imported by the assembly. /// - [Fact] + [TestMethod] public async Task GetTypeRefs_RichLibrary_ReturnsImportedTypes() { await StartServerAsync(); @@ -100,19 +102,19 @@ public async Task GetTypeRefs_RichLibrary_ReturnsImportedTypes() var result = await client.CallToolAsync( "get_type_refs", - new Dictionary { ["assemblyPath"] = samples.RichLibraryDll }, + new Dictionary { ["assemblyPath"] = Samples.RichLibraryDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var refs = JsonSerializer.Deserialize(text); - Assert.True(refs.GetArrayLength() > 0); + Assert.IsGreaterThan(0, refs.GetArrayLength()); } /// /// Even a nearly empty assembly still references System.Runtime at minimum. /// - [Fact] + [TestMethod] public async Task GetAssemblyRefs_EmptyLib_ReturnsAtLeastSystemRuntime() { await StartServerAsync(); @@ -120,12 +122,12 @@ public async Task GetAssemblyRefs_EmptyLib_ReturnsAtLeastSystemRuntime() var result = await client.CallToolAsync( "get_assembly_refs", - new Dictionary { ["assemblyPath"] = samples.EmptyLibDll }, + new Dictionary { ["assemblyPath"] = Samples.EmptyLibDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var refs = JsonSerializer.Deserialize(text); - Assert.True(refs.GetArrayLength() >= 1); + Assert.IsGreaterThanOrEqualTo(1, refs.GetArrayLength()); } } diff --git a/tests/Dotsider.Mcp.Tests/DiffToolsTests.cs b/tests/Dotsider.Mcp.Tests/DiffToolsTests.cs index eebf4702..a69e865f 100644 --- a/tests/Dotsider.Mcp.Tests/DiffToolsTests.cs +++ b/tests/Dotsider.Mcp.Tests/DiffToolsTests.cs @@ -8,13 +8,15 @@ namespace Dotsider.Mcp.Tests; /// /// Creates the tests using the shared sample assembly fixture. /// -[Collection("SampleAssemblies")] -public class DiffToolsTests(SampleAssemblyFixture samples) : McpServerTestBase +[TestClass] +public class DiffToolsTests : McpServerTestBase { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// Comparing v1 to v2 of the same library produces a non-error diff payload. /// - [Fact] + [TestMethod] public async Task DiffAssemblies_V1VsV2_ReturnsDifferences() { await StartServerAsync(); @@ -24,20 +26,20 @@ public async Task DiffAssemblies_V1VsV2_ReturnsDifferences() "diff_assemblies", new Dictionary { - ["leftPath"] = samples.RichLibraryDll, - ["rightPath"] = samples.RichLibraryV2Dll + ["leftPath"] = Samples.RichLibraryDll, + ["rightPath"] = Samples.RichLibraryV2Dll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.DoesNotContain("Error", text); } /// /// Diffing an assembly against itself produces an empty, error-free diff. /// - [Fact] + [TestMethod] public async Task DiffAssemblies_SameAssembly_ReturnsNoDifferences() { await StartServerAsync(); @@ -47,20 +49,20 @@ public async Task DiffAssemblies_SameAssembly_ReturnsNoDifferences() "diff_assemblies", new Dictionary { - ["leftPath"] = samples.HelloWorldDll, - ["rightPath"] = samples.HelloWorldDll + ["leftPath"] = Samples.HelloWorldDll, + ["rightPath"] = Samples.HelloWorldDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.DoesNotContain("Error", text); } /// /// maxTypeDiffs caps the typeDiffs array while metadataSummary retains full counts. /// - [Fact] + [TestMethod] public async Task DiffAssemblies_MaxTypeDiffs_LimitsTypeOutput() { await StartServerAsync(); @@ -70,30 +72,30 @@ public async Task DiffAssemblies_MaxTypeDiffs_LimitsTypeOutput() "diff_assemblies", new Dictionary { - ["leftPath"] = samples.RichLibraryDll, - ["rightPath"] = samples.RichLibraryV2Dll, + ["leftPath"] = Samples.RichLibraryDll, + ["rightPath"] = Samples.RichLibraryV2Dll, ["maxTypeDiffs"] = 2 }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); var typeDiffs = json.GetProperty("typeDiffs"); - Assert.True(typeDiffs.GetArrayLength() <= 2); + Assert.IsLessThanOrEqualTo(2, typeDiffs.GetArrayLength()); // Summary should still reflect full counts var summary = json.GetProperty("metadataSummary"); var totalTypes = summary.GetProperty("typesAdded").GetInt32() + summary.GetProperty("typesRemoved").GetInt32() + summary.GetProperty("typesChanged").GetInt32(); - Assert.True(totalTypes > 2, "Summary should reflect all diffs, not the limited output"); + Assert.IsGreaterThan(2, totalTypes, "Summary should reflect all diffs, not the limited output"); } /// /// maxMethodDiffs caps the methodDiffs array without altering the aggregate summary. /// - [Fact] + [TestMethod] public async Task DiffAssemblies_MaxMethodDiffs_LimitsMethodOutput() { await StartServerAsync(); @@ -103,30 +105,30 @@ public async Task DiffAssemblies_MaxMethodDiffs_LimitsMethodOutput() "diff_assemblies", new Dictionary { - ["leftPath"] = samples.RichLibraryDll, - ["rightPath"] = samples.RichLibraryV2Dll, + ["leftPath"] = Samples.RichLibraryDll, + ["rightPath"] = Samples.RichLibraryV2Dll, ["maxMethodDiffs"] = 5 }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); var methodDiffs = json.GetProperty("methodDiffs"); - Assert.True(methodDiffs.GetArrayLength() <= 5); + Assert.IsLessThanOrEqualTo(5, methodDiffs.GetArrayLength()); // Summary should still reflect full counts var summary = json.GetProperty("metadataSummary"); var totalMethods = summary.GetProperty("methodsAdded").GetInt32() + summary.GetProperty("methodsRemoved").GetInt32() + summary.GetProperty("methodsChanged").GetInt32(); - Assert.True(totalMethods > 5, "Summary should reflect all diffs, not the limited output"); + Assert.IsGreaterThan(5, totalMethods, "Summary should reflect all diffs, not the limited output"); } /// /// Type and method limits compose independently on the same diff invocation. /// - [Fact] + [TestMethod] public async Task DiffAssemblies_BothLimits_LimitsBothOutputs() { await StartServerAsync(); @@ -136,18 +138,18 @@ public async Task DiffAssemblies_BothLimits_LimitsBothOutputs() "diff_assemblies", new Dictionary { - ["leftPath"] = samples.RichLibraryDll, - ["rightPath"] = samples.RichLibraryV2Dll, + ["leftPath"] = Samples.RichLibraryDll, + ["rightPath"] = Samples.RichLibraryV2Dll, ["maxTypeDiffs"] = 3, ["maxMethodDiffs"] = 10 }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.True(json.GetProperty("typeDiffs").GetArrayLength() <= 3); - Assert.True(json.GetProperty("methodDiffs").GetArrayLength() <= 10); + Assert.IsLessThanOrEqualTo(3, json.GetProperty("typeDiffs").GetArrayLength()); + Assert.IsLessThanOrEqualTo(10, json.GetProperty("methodDiffs").GetArrayLength()); } // --- diff_size --- @@ -156,18 +158,19 @@ public async Task DiffAssemblies_BothLimits_LimitsBothOutputs() private static readonly string[] s_generousGrowthBudget = ["total:growth=1000%"]; private static readonly string[] s_bareGrowthBudget = ["growth=1%"]; - private (string V1, string V2) RequireMstats() + private static (string V1, string V2) RequireMstats() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "V1 mstat sidecar was not produced"); - Assert.SkipWhen(samples.NativeAotConsoleV2Mstat is null, "V2 mstat sidecar was not produced"); - return (samples.NativeAotConsoleMstat!, samples.NativeAotConsoleV2Mstat!); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "V1 mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleV2Mstat is null, "V2 mstat sidecar was not produced"); + return (Samples.NativeAotConsoleMstat!, Samples.NativeAotConsoleV2Mstat!); } /// /// diff_size over the real V1/V2 mstat pair returns the summary, aggregates, and top /// contributors — and no tree unless asked. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task DiffSize_V1V2Mstats_ReturnsSummaryAndContributors() { var (v1, v2) = RequireMstats(); @@ -185,17 +188,18 @@ public async Task DiffSize_V1V2Mstats_ReturnsSummaryAndContributors() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.NotEqual(0, json.GetProperty("summary").GetProperty("delta").GetInt64()); - Assert.True(json.GetProperty("assemblyDeltas").GetArrayLength() > 0); - Assert.True(json.GetProperty("namespaceDeltas").GetArrayLength() > 0); - Assert.True(json.GetProperty("contributors").GetArrayLength() <= 5); - Assert.False(json.TryGetProperty("root", out var root) && root.ValueKind != JsonValueKind.Null); + Assert.AreNotEqual(0, json.GetProperty("summary").GetProperty("delta").GetInt64()); + Assert.IsGreaterThan(0, json.GetProperty("assemblyDeltas").GetArrayLength()); + Assert.IsGreaterThan(0, json.GetProperty("namespaceDeltas").GetArrayLength()); + Assert.IsLessThanOrEqualTo(5, json.GetProperty("contributors").GetArrayLength()); + Assert.IsFalse(json.TryGetProperty("root", out var root) && root.ValueKind != JsonValueKind.Null); } /// diff_size of a report against itself returns a zero delta and no contributors. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task DiffSize_SelfDiff_ZeroDelta() { var (v1, _) = RequireMstats(); @@ -212,17 +216,18 @@ public async Task DiffSize_SelfDiff_ZeroDelta() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.Equal(0, json.GetProperty("summary").GetProperty("delta").GetInt64()); - Assert.Equal(0, json.GetProperty("contributors").GetArrayLength()); + Assert.AreEqual(0, json.GetProperty("summary").GetProperty("delta").GetInt64()); + Assert.AreEqual(0, json.GetProperty("contributors").GetArrayLength()); } /// /// includeTree with a tight node cap emits a pruned tree and says so through the /// truncation metadata — deterministic, never a silent sample. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task DiffSize_IncludeTreeWithCap_SetsTruncationMetadata() { var (v1, v2) = RequireMstats(); @@ -241,16 +246,17 @@ public async Task DiffSize_IncludeTreeWithCap_SetsTruncationMetadata() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.NotEqual(JsonValueKind.Null, json.GetProperty("root").ValueKind); - Assert.True(json.GetProperty("treeTruncated").GetBoolean()); - Assert.True(json.GetProperty("treeTotalNodes").GetInt32() > 10); - Assert.True(json.GetProperty("treeIncludedNodes").GetInt32() <= 10); + Assert.AreNotEqual(JsonValueKind.Null, json.GetProperty("root").ValueKind); + Assert.IsTrue(json.GetProperty("treeTruncated").GetBoolean()); + Assert.IsGreaterThan(10, json.GetProperty("treeTotalNodes").GetInt32()); + Assert.IsLessThanOrEqualTo(10, json.GetProperty("treeIncludedNodes").GetInt32()); } /// diff_size rejects an input that is not mstat-backed with a message naming the fix. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task DiffSize_NonMstatInput_ReturnsError() { var (v1, _) = RequireMstats(); @@ -262,12 +268,12 @@ public async Task DiffSize_NonMstatInput_ReturnsError() new Dictionary { ["leftPath"] = v1, - ["rightPath"] = samples.RichLibraryDll + ["rightPath"] = Samples.RichLibraryDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains("not mstat-backed", text); } @@ -277,7 +283,8 @@ public async Task DiffSize_NonMstatInput_ReturnsError() /// A zero-growth budget on the namespace added in V2 breaches, and the report carries the /// violation and its scoped contributors. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task CheckSizeBudgets_Breach_ReportsFailed() { var (v1, v2) = RequireMstats(); @@ -295,16 +302,17 @@ public async Task CheckSizeBudgets_Breach_ReportsFailed() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.False(json.GetProperty("passed").GetBoolean()); + Assert.IsFalse(json.GetProperty("passed").GetBoolean()); var evaluation = json.GetProperty("evaluations")[0]; - Assert.True(evaluation.GetProperty("violations").GetArrayLength() > 0); - Assert.True(evaluation.GetProperty("topContributors").GetArrayLength() > 0); + Assert.IsGreaterThan(0, evaluation.GetProperty("violations").GetArrayLength()); + Assert.IsGreaterThan(0, evaluation.GetProperty("topContributors").GetArrayLength()); } /// A generous budget passes. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task CheckSizeBudgets_Pass_ReportsPassed() { var (v1, v2) = RequireMstats(); @@ -322,16 +330,17 @@ public async Task CheckSizeBudgets_Pass_ReportsPassed() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.True(json.GetProperty("passed").GetBoolean()); + Assert.IsTrue(json.GetProperty("passed").GetBoolean()); } /// /// budgetsJson carries the object form — named budgets with warning severity — at full /// parity with the CLI's budget file. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task CheckSizeBudgets_BudgetsJson_ObjectFormHonored() { var (v1, v2) = RequireMstats(); @@ -357,19 +366,20 @@ public async Task CheckSizeBudgets_BudgetsJson_ObjectFormHonored() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.True(json.GetProperty("passed").GetBoolean(), "warning severity must not fail the check"); - Assert.True(json.GetProperty("hasWarnings").GetBoolean()); + Assert.IsTrue(json.GetProperty("passed").GetBoolean(), "warning severity must not fail the check"); + Assert.IsTrue(json.GetProperty("hasWarnings").GetBoolean()); var evaluation = json.GetProperty("evaluations")[0]; - Assert.False(evaluation.GetProperty("passed").GetBoolean()); - Assert.Equal("telemetry-watch", + Assert.IsFalse(evaluation.GetProperty("passed").GetBoolean()); + Assert.AreEqual("telemetry-watch", evaluation.GetProperty("budget").GetProperty("name").GetString()); - Assert.True(evaluation.GetProperty("topContributors").GetArrayLength() <= 3); + Assert.IsLessThanOrEqualTo(3, evaluation.GetProperty("topContributors").GetArrayLength()); } /// budgetFilePath loads the same document schema from disk. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task CheckSizeBudgets_BudgetFilePath_Honored() { var (v1, v2) = RequireMstats(); @@ -392,9 +402,9 @@ await File.WriteAllTextAsync( cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.True(json.GetProperty("passed").GetBoolean()); + Assert.IsTrue(json.GetProperty("passed").GetBoolean()); } finally { @@ -403,7 +413,8 @@ await File.WriteAllTextAsync( } /// Every budget source absent is an error, not an empty pass. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task CheckSizeBudgets_NoBudgetSource_ReturnsError() { var (v1, v2) = RequireMstats(); @@ -420,12 +431,13 @@ public async Task CheckSizeBudgets_NoBudgetSource_ReturnsError() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains("budget source is required", text); } /// A growth budget without a baseline is an error naming the missing parameter. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task CheckSizeBudgets_GrowthWithoutBaseline_ReturnsError() { var (_, v2) = RequireMstats(); @@ -442,7 +454,7 @@ public async Task CheckSizeBudgets_GrowthWithoutBaseline_ReturnsError() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains("baselinePath", text); } } diff --git a/tests/Dotsider.Mcp.Tests/Dotsider.Mcp.Tests.csproj b/tests/Dotsider.Mcp.Tests/Dotsider.Mcp.Tests.csproj index 755737c6..bf1d840f 100644 --- a/tests/Dotsider.Mcp.Tests/Dotsider.Mcp.Tests.csproj +++ b/tests/Dotsider.Mcp.Tests/Dotsider.Mcp.Tests.csproj @@ -1,34 +1,27 @@ - + - - net10.0 - enable - enable - false - true - true - true - + + net10.0 + enable + enable + false + true + true + true + - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - + + + - - - - - + + + + + - - - + + + diff --git a/tests/Dotsider.Mcp.Tests/FieldToolsTests.cs b/tests/Dotsider.Mcp.Tests/FieldToolsTests.cs index 687fffcc..0af2b0bb 100644 --- a/tests/Dotsider.Mcp.Tests/FieldToolsTests.cs +++ b/tests/Dotsider.Mcp.Tests/FieldToolsTests.cs @@ -5,33 +5,37 @@ namespace Dotsider.Mcp.Tests; /// /// Tests for field definition listing MCP tools. /// -[Collection("SampleAssemblies")] -public class FieldToolsTests(SampleAssemblyFixture samples) : McpServerTestBase +[TestClass] +public class FieldToolsTests : McpServerTestBase { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// list_fields returns a non-empty JSON array of field entries for a real library. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ListFields_ReturnsFields() { await StartServerAsync(); await using var client = await CreateClientAsync(); var result = await client.CallToolAsync("list_fields", - new Dictionary { ["assemblyPath"] = samples.RichLibraryDll }, + new Dictionary { ["assemblyPath"] = Samples.RichLibraryDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var fields = JsonSerializer.Deserialize(text); - Assert.Equal(JsonValueKind.Array, fields.ValueKind); - Assert.True(fields.GetArrayLength() > 0); + Assert.AreEqual(JsonValueKind.Array, fields.ValueKind); + Assert.IsGreaterThan(0, fields.GetArrayLength()); } /// /// A query narrows list_fields to fields whose names match the substring. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ListFields_WithQuery_Filters() { await StartServerAsync(); @@ -40,15 +44,15 @@ public async Task ListFields_WithQuery_Filters() var result = await client.CallToolAsync("list_fields", new Dictionary { - ["assemblyPath"] = samples.RichLibraryDll, + ["assemblyPath"] = Samples.RichLibraryDll, ["query"] = "_counter" }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var fields = JsonSerializer.Deserialize(text); - Assert.Equal(JsonValueKind.Array, fields.ValueKind); + Assert.AreEqual(JsonValueKind.Array, fields.ValueKind); // All results should contain "_counter" in their name foreach (var field in fields.EnumerateArray()) Assert.Contains("_counter", field.GetProperty("name").GetString()!, StringComparison.OrdinalIgnoreCase); @@ -57,7 +61,8 @@ public async Task ListFields_WithQuery_Filters() /// /// typeName restricts list_fields to members of the specified declaring type. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ListFields_WithTypeName_Filters() { await StartServerAsync(); @@ -66,13 +71,13 @@ public async Task ListFields_WithTypeName_Filters() var result = await client.CallToolAsync("list_fields", new Dictionary { - ["assemblyPath"] = samples.RichLibraryDll, + ["assemblyPath"] = Samples.RichLibraryDll, ["typeName"] = "IlNavigationFixture" }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var fields = JsonSerializer.Deserialize(text); foreach (var field in fields.EnumerateArray()) Assert.Contains("IlNavigationFixture", field.GetProperty("declaringType").GetString()!); @@ -81,12 +86,13 @@ public async Task ListFields_WithTypeName_Filters() /// /// Tool registry advertises list_fields alongside the other assembly tools. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ListTools_IncludesFieldTools() { await StartServerAsync(); await using var client = await CreateClientAsync(); var tools = await client.ListToolsAsync(cancellationToken: TestCancellationToken); - Assert.Contains(tools, t => t.Name == "list_fields"); + Assert.Contains(t => t.Name == "list_fields", tools); } } diff --git a/tests/Dotsider.Mcp.Tests/IlToolsTests.cs b/tests/Dotsider.Mcp.Tests/IlToolsTests.cs index 1fe75102..82cdbaa8 100644 --- a/tests/Dotsider.Mcp.Tests/IlToolsTests.cs +++ b/tests/Dotsider.Mcp.Tests/IlToolsTests.cs @@ -8,13 +8,15 @@ namespace Dotsider.Mcp.Tests; /// /// Creates the tests using the shared sample assembly fixture. /// -[Collection("SampleAssemblies")] -public class IlToolsTests(SampleAssemblyFixture samples) : McpServerTestBase +[TestClass] +public class IlToolsTests : McpServerTestBase { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// disassemble_method returns an instruction list for an existing method body. /// - [Fact] + [TestMethod] public async Task DisassembleMethod_ValidMethod_ReturnsIlInstructions() { await StartServerAsync(); @@ -24,23 +26,23 @@ public async Task DisassembleMethod_ValidMethod_ReturnsIlInstructions() "disassemble_method", new Dictionary { - ["assemblyPath"] = samples.RichLibraryDll, + ["assemblyPath"] = Samples.RichLibraryDll, ["typeName"] = "UserService", ["methodName"] = "Add" }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.True(json.TryGetProperty("instructions", out var instructions)); - Assert.True(instructions.GetArrayLength() > 0); + Assert.IsTrue(json.TryGetProperty("instructions", out var instructions)); + Assert.IsGreaterThan(0, instructions.GetArrayLength()); } /// /// disassemble_method can include portable PDB debug information when requested. /// - [Fact] + [TestMethod] public async Task DisassembleMethod_WithDebugInfo_ReturnsPortablePdbData() { await StartServerAsync(); @@ -50,7 +52,7 @@ public async Task DisassembleMethod_WithDebugInfo_ReturnsPortablePdbData() "disassemble_method", new Dictionary { - ["assemblyPath"] = samples.RichLibraryDll, + ["assemblyPath"] = Samples.RichLibraryDll, ["typeName"] = "UserService", ["methodName"] = "Add", ["includeDebugInfo"] = true @@ -58,21 +60,20 @@ public async Task DisassembleMethod_WithDebugInfo_ReturnsPortablePdbData() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.Equal("sidecar", json.GetProperty("pdb").GetProperty("kind").GetString()); - Assert.True(json.GetProperty("sourceLink").GetProperty("isPresent").GetBoolean()); - Assert.True(json.GetProperty("debugInfo").GetProperty("sequencePoints").GetArrayLength() > 0); - Assert.Contains(json.GetProperty("instructions").EnumerateArray(), - instruction => instruction.TryGetProperty("localName", out var localName) - && localName.GetString() == "id"); + Assert.AreEqual("sidecar", json.GetProperty("pdb").GetProperty("kind").GetString()); + Assert.IsTrue(json.GetProperty("sourceLink").GetProperty("isPresent").GetBoolean()); + Assert.IsGreaterThan(0, json.GetProperty("debugInfo").GetProperty("sequencePoints").GetArrayLength()); + Assert.Contains(instruction => instruction.TryGetProperty("localName", out var localName) + && localName.GetString() == "id", json.GetProperty("instructions").EnumerateArray()); } /// /// get_method_debug_info returns sequence points and local names for a method. /// - [Fact] + [TestMethod] public async Task GetMethodDebugInfo_ReturnsSequencePointsAndLocals() { await StartServerAsync(); @@ -82,28 +83,26 @@ public async Task GetMethodDebugInfo_ReturnsSequencePointsAndLocals() "get_method_debug_info", new Dictionary { - ["assemblyPath"] = samples.RichLibraryDll, + ["assemblyPath"] = Samples.RichLibraryDll, ["typeName"] = "UserService", ["methodName"] = "Add" }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.Equal("sidecar", json.GetProperty("pdb").GetProperty("kind").GetString()); - Assert.Contains(json.GetProperty("sequencePoints").EnumerateArray(), - point => point.GetProperty("document").GetString()?.EndsWith("UserService.cs", - StringComparison.OrdinalIgnoreCase) == true); - Assert.Contains(json.GetProperty("locals").EnumerateArray(), - local => local.GetProperty("name").GetString() == "id"); + Assert.AreEqual("sidecar", json.GetProperty("pdb").GetProperty("kind").GetString()); + Assert.Contains(point => point.GetProperty("document").GetString()?.EndsWith("UserService.cs", + StringComparison.OrdinalIgnoreCase) == true, json.GetProperty("sequencePoints").EnumerateArray()); + Assert.Contains(local => local.GetProperty("name").GetString() == "id", json.GetProperty("locals").EnumerateArray()); } /// /// get_source_link returns decoded Source Link mappings. /// - [Fact] + [TestMethod] public async Task GetSourceLink_ReturnsMappings() { await StartServerAsync(); @@ -113,24 +112,23 @@ public async Task GetSourceLink_ReturnsMappings() "get_source_link", new Dictionary { - ["assemblyPath"] = samples.RichLibraryDll + ["assemblyPath"] = Samples.RichLibraryDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.True(json.GetProperty("isPresent").GetBoolean()); - Assert.Contains(json.GetProperty("mappings").EnumerateArray(), - mapping => mapping.GetProperty("urlTemplate").GetString()?.Contains("raw.githubusercontent.com", - StringComparison.OrdinalIgnoreCase) == true); + Assert.IsTrue(json.GetProperty("isPresent").GetBoolean()); + Assert.Contains(mapping => mapping.GetProperty("urlTemplate").GetString()?.Contains("raw.githubusercontent.com", + StringComparison.OrdinalIgnoreCase) == true, json.GetProperty("mappings").EnumerateArray()); } /// /// Requesting IL for a method that does not exist yields a descriptive error. /// - [Fact] + [TestMethod] public async Task DisassembleMethod_NonExistentMethod_ReturnsError() { await StartServerAsync(); @@ -140,21 +138,21 @@ public async Task DisassembleMethod_NonExistentMethod_ReturnsError() "disassemble_method", new Dictionary { - ["assemblyPath"] = samples.HelloWorldDll, + ["assemblyPath"] = Samples.HelloWorldDll, ["typeName"] = "Program", ["methodName"] = "NonExistentMethod" }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains("Error", text); } /// /// search_il_opcodes locates call-family instructions across the assembly's bodies. /// - [Fact] + [TestMethod] public async Task SearchIlOpcodes_CallInstruction_FindsMatches() { await StartServerAsync(); @@ -164,22 +162,22 @@ public async Task SearchIlOpcodes_CallInstruction_FindsMatches() "search_il_opcodes", new Dictionary { - ["assemblyPath"] = samples.RichLibraryDll, + ["assemblyPath"] = Samples.RichLibraryDll, ["query"] = "call", ["maxResults"] = 5 }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var results = JsonSerializer.Deserialize(text); - Assert.True(results.GetArrayLength() > 0); + Assert.IsGreaterThan(0, results.GetArrayLength()); } /// /// search_il_opcodes surfaces newobj sites for identifying object allocations. /// - [Fact] + [TestMethod] public async Task SearchIlOpcodes_NewobjInstruction_FindsObjectCreation() { await StartServerAsync(); @@ -189,15 +187,15 @@ public async Task SearchIlOpcodes_NewobjInstruction_FindsObjectCreation() "search_il_opcodes", new Dictionary { - ["assemblyPath"] = samples.ComplexAppDll, + ["assemblyPath"] = Samples.ComplexAppDll, ["query"] = "newobj", ["maxResults"] = 10 }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var results = JsonSerializer.Deserialize(text); - Assert.Equal(JsonValueKind.Array, results.ValueKind); + Assert.AreEqual(JsonValueKind.Array, results.ValueKind); } } diff --git a/tests/Dotsider.Mcp.Tests/McpCliTests.cs b/tests/Dotsider.Mcp.Tests/McpCliTests.cs index 9b6e4873..0d1c8b49 100644 --- a/tests/Dotsider.Mcp.Tests/McpCliTests.cs +++ b/tests/Dotsider.Mcp.Tests/McpCliTests.cs @@ -9,6 +9,7 @@ namespace Dotsider.Mcp.Tests; /// /// Process-level tests for the dotsider-mcp CLI entry point. /// +[TestClass] public partial class McpCliTests { private static readonly string s_projectPath = Path.Combine( @@ -19,12 +20,12 @@ public partial class McpCliTests /// /// --help prints usage text identifying the binary and exits with success. /// - [Fact] + [TestMethod] public async Task Help_ShowsUsageAndReturnsZero() { var (exitCode, stdout, _) = await RunMcpAsync("--help"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("dotsider-mcp", stdout); Assert.Contains("MCP", stdout); } @@ -32,19 +33,19 @@ public async Task Help_ShowsUsageAndReturnsZero() /// /// --version emits a non-empty version string and a zero exit code. /// - [Fact] + [TestMethod] public async Task Version_ReturnsZero() { var (exitCode, stdout, _) = await RunMcpAsync("--version"); - Assert.Equal(0, exitCode); - Assert.False(string.IsNullOrWhiteSpace(stdout)); + Assert.AreEqual(0, exitCode); + Assert.IsFalse(string.IsNullOrWhiteSpace(stdout)); } /// /// Launched with no arguments, the CLI boots the MCP server and completes the client handshake. /// - [Fact] + [TestMethod] public async Task NoArgs_StartsServerAndCompletesHandshake() { using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(15)); @@ -53,20 +54,21 @@ public async Task NoArgs_StartsServerAndCompletesHandshake() // which has project resolution overhead that can exceed the timeout. var dllPath = Path.Combine( FindRepoRoot(), "src", "Dotsider.Mcp", "bin", s_buildConfig, "net10.0", "dotsider-mcp.dll"); - Assert.True(File.Exists(dllPath), $"dotsider-mcp.dll not found: {dllPath}"); + Assert.IsTrue(File.Exists(dllPath), $"dotsider-mcp.dll not found: {dllPath}"); await using var client = await McpClient.CreateAsync( new StdioClientTransport(new StdioClientTransportOptions { Command = "dotnet", Arguments = [dllPath], + EnvironmentVariables = TestProcessEnvironment.WithoutCodeCoverage(), }), cancellationToken: cts.Token); // If we get here, the MCP handshake completed successfully. // Verify the server reports its tools. var tools = await client.ListToolsAsync(cancellationToken: cts.Token); - Assert.NotEmpty(tools); + Assert.IsNotEmpty(tools); } private static async Task<(int ExitCode, string Stdout, string Stderr)> RunMcpAsync( @@ -80,6 +82,7 @@ public async Task NoArgs_StartsServerAndCompletesHandshake() RedirectStandardOutput = true, RedirectStandardError = true, }; + TestProcessEnvironment.RemoveCodeCoverageVariables(psi); var process = Process.Start(psi)!; var stdout = await process.StandardOutput.ReadToEndAsync(); @@ -96,35 +99,32 @@ public async Task NoArgs_StartsServerAndCompletesHandshake() /// the orphaned process's read() on the terminal returns EIO → IOException. /// Regression test for https://github.com/willibrandon/dotsider/issues/108. /// - [Fact] + [TestMethod] + [OSCondition(ConditionMode.Exclude, OperatingSystems.Windows)] public async Task CtrlC_InTerminal_ShutsDownWithoutTransportException() { - if (OperatingSystem.IsWindows()) - return; // Windows console handles Ctrl+C cleanly; this is a macOS/Linux issue - var repoRoot = FindRepoRoot(); var dotsiderExe = Path.Combine( repoRoot, "src", "Dotsider", "bin", s_buildConfig, "net10.0", "dotsider"); var mcpDir = Path.Combine( repoRoot, "src", "Dotsider.Mcp", "bin", s_buildConfig, "net10.0"); - Assert.True(File.Exists(dotsiderExe), $"dotsider not found: {dotsiderExe}"); - Assert.True(File.Exists(Path.Combine(mcpDir, "dotsider-mcp")), + Assert.IsTrue(File.Exists(dotsiderExe), $"dotsider not found: {dotsiderExe}"); + Assert.IsTrue(File.Exists(Path.Combine(mcpDir, "dotsider-mcp")), $"dotsider-mcp not found in: {mcpDir}"); // Start an interactive shell in the PTY (bash becomes session leader). // This mirrors the real user experience: shell → dotsider → dotsider-mcp. - var env = new Dictionary - { - ["PATH"] = $"{mcpDir}:{Environment.GetEnvironmentVariable("PATH")}" - }; + var env = TestProcessEnvironment.WithoutCodeCoverageStringValues(); + env["PATH"] = $"{mcpDir}:{Environment.GetEnvironmentVariable("PATH")}"; await using var pty = new Hex1bTerminalChildProcess( "/bin/bash", ["--norc", "--noprofile"], environment: env, + inheritEnvironment: false, initialWidth: 160, initialHeight: 24); - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; await pty.StartAsync(ct); var output = new StringBuilder(); diff --git a/tests/Dotsider.Mcp.Tests/McpServerTestBase.cs b/tests/Dotsider.Mcp.Tests/McpServerTestBase.cs index ae6ef635..1c2ee9bf 100644 --- a/tests/Dotsider.Mcp.Tests/McpServerTestBase.cs +++ b/tests/Dotsider.Mcp.Tests/McpServerTestBase.cs @@ -120,6 +120,7 @@ protected async Task CreateClientAsync(McpClientOptions? clientOption /// public virtual async ValueTask DisposeAsync() { + GC.SuppressFinalize(this); await _cts.CancelAsync(); _clientToServerPipe.Writer.Complete(); @@ -143,6 +144,5 @@ public virtual async ValueTask DisposeAsync() _cts.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Mcp.Tests/MetadataToolsTests.cs b/tests/Dotsider.Mcp.Tests/MetadataToolsTests.cs index 2c9ae66b..f212d378 100644 --- a/tests/Dotsider.Mcp.Tests/MetadataToolsTests.cs +++ b/tests/Dotsider.Mcp.Tests/MetadataToolsTests.cs @@ -8,13 +8,15 @@ namespace Dotsider.Mcp.Tests; /// /// Creates the tests using the shared sample assembly fixture. /// -[Collection("SampleAssemblies")] -public class MetadataToolsTests(SampleAssemblyFixture samples) : McpServerTestBase +[TestClass] +public class MetadataToolsTests : McpServerTestBase { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// get_pe_headers returns parsed PE header info without errors for a valid assembly. /// - [Fact] + [TestMethod] public async Task GetPeHeaders_ValidAssembly_ReturnsHeaders() { await StartServerAsync(); @@ -22,18 +24,18 @@ public async Task GetPeHeaders_ValidAssembly_ReturnsHeaders() var result = await client.CallToolAsync( "get_pe_headers", - new Dictionary { ["assemblyPath"] = samples.HelloWorldDll }, + new Dictionary { ["assemblyPath"] = Samples.HelloWorldDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.DoesNotContain("Error", text); } /// /// get_clr_header returns CLR directory info without errors for a managed assembly. /// - [Fact] + [TestMethod] public async Task GetClrHeader_ValidAssembly_ReturnsClrInfo() { await StartServerAsync(); @@ -41,18 +43,18 @@ public async Task GetClrHeader_ValidAssembly_ReturnsClrInfo() var result = await client.CallToolAsync( "get_clr_header", - new Dictionary { ["assemblyPath"] = samples.HelloWorldDll }, + new Dictionary { ["assemblyPath"] = Samples.HelloWorldDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.DoesNotContain("Error", text); } /// /// get_sections enumerates the PE section table as a non-empty JSON array. /// - [Fact] + [TestMethod] public async Task GetSections_ValidAssembly_ReturnsSections() { await StartServerAsync(); @@ -60,19 +62,19 @@ public async Task GetSections_ValidAssembly_ReturnsSections() var result = await client.CallToolAsync( "get_sections", - new Dictionary { ["assemblyPath"] = samples.RichLibraryDll }, + new Dictionary { ["assemblyPath"] = Samples.RichLibraryDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var sections = JsonSerializer.Deserialize(text); - Assert.True(sections.GetArrayLength() > 0); + Assert.IsGreaterThan(0, sections.GetArrayLength()); } /// /// get_custom_attributes returns at least one attribute for a real library. /// - [Fact] + [TestMethod] public async Task GetCustomAttributes_ValidAssembly_ReturnsAttributes() { await StartServerAsync(); @@ -80,19 +82,19 @@ public async Task GetCustomAttributes_ValidAssembly_ReturnsAttributes() var result = await client.CallToolAsync( "get_custom_attributes", - new Dictionary { ["assemblyPath"] = samples.RichLibraryDll }, + new Dictionary { ["assemblyPath"] = Samples.RichLibraryDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var attrs = JsonSerializer.Deserialize(text); - Assert.True(attrs.GetArrayLength() > 0); + Assert.IsGreaterThan(0, attrs.GetArrayLength()); } /// /// By default, compiler-generated attributes like Nullable/CompilerGenerated are filtered out. /// - [Fact] + [TestMethod] public async Task GetCustomAttributes_DefaultFiltering_ExcludesCompilerGenerated() { await StartServerAsync(); @@ -100,11 +102,11 @@ public async Task GetCustomAttributes_DefaultFiltering_ExcludesCompilerGenerated var result = await client.CallToolAsync( "get_custom_attributes", - new Dictionary { ["assemblyPath"] = samples.RichLibraryDll }, + new Dictionary { ["assemblyPath"] = Samples.RichLibraryDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.DoesNotContain("CompilerGeneratedAttribute", text); Assert.DoesNotContain("NullableContextAttribute", text); Assert.DoesNotContain("NullableAttribute", text); @@ -114,7 +116,7 @@ public async Task GetCustomAttributes_DefaultFiltering_ExcludesCompilerGenerated /// /// Opting in via includeCompilerGenerated re-exposes the noisy compiler attributes. /// - [Fact] + [TestMethod] public async Task GetCustomAttributes_IncludeCompilerGenerated_ReturnsAll() { await StartServerAsync(); @@ -124,13 +126,13 @@ public async Task GetCustomAttributes_IncludeCompilerGenerated_ReturnsAll() "get_custom_attributes", new Dictionary { - ["assemblyPath"] = samples.RichLibraryDll, + ["assemblyPath"] = Samples.RichLibraryDll, ["includeCompilerGenerated"] = true }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); // With includeCompilerGenerated=true, these should be present Assert.Contains("CompilerGeneratedAttribute", text); } @@ -138,7 +140,7 @@ public async Task GetCustomAttributes_IncludeCompilerGenerated_ReturnsAll() /// /// The advertised tool schema surfaces the includeCompilerGenerated parameter to clients. /// - [Fact] + [TestMethod] public async Task GetCustomAttributes_ToolSchema_IncludesFilterParameter() { await StartServerAsync(); @@ -153,7 +155,7 @@ public async Task GetCustomAttributes_ToolSchema_IncludesFilterParameter() /// /// get_resources always returns a JSON array, even for assemblies with no embedded resources. /// - [Fact] + [TestMethod] public async Task GetResources_ValidAssembly_ReturnsResourceList() { await StartServerAsync(); @@ -161,19 +163,19 @@ public async Task GetResources_ValidAssembly_ReturnsResourceList() var result = await client.CallToolAsync( "get_resources", - new Dictionary { ["assemblyPath"] = samples.HelloWorldDll }, + new Dictionary { ["assemblyPath"] = Samples.HelloWorldDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); - Assert.Equal(JsonValueKind.Array, + Assert.IsNotNull(text); + Assert.AreEqual(JsonValueKind.Array, JsonSerializer.Deserialize(text).ValueKind); } /// /// resolve_token turns a raw metadata token into a human-readable member name. /// - [Fact] + [TestMethod] public async Task ResolveToken_ValidToken_ReturnsResolvedName() { await StartServerAsync(); @@ -184,15 +186,15 @@ public async Task ResolveToken_ValidToken_ReturnsResolvedName() "resolve_token", new Dictionary { - ["assemblyPath"] = samples.HelloWorldDll, + ["assemblyPath"] = Samples.HelloWorldDll, ["token"] = 0x02000002 }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.True(json.TryGetProperty("resolved", out var resolved)); - Assert.False(string.IsNullOrEmpty(resolved.GetString())); + Assert.IsTrue(json.TryGetProperty("resolved", out var resolved)); + Assert.IsFalse(string.IsNullOrEmpty(resolved.GetString())); } } diff --git a/tests/Dotsider.Mcp.Tests/NativeAotToolsTests.cs b/tests/Dotsider.Mcp.Tests/NativeAotToolsTests.cs index 3a22abd5..6f1b9e31 100644 --- a/tests/Dotsider.Mcp.Tests/NativeAotToolsTests.cs +++ b/tests/Dotsider.Mcp.Tests/NativeAotToolsTests.cs @@ -5,60 +5,65 @@ namespace Dotsider.Mcp.Tests; /// /// Tests for Native AOT-specific MCP tools. /// -[Collection("SampleAssemblies")] -public class NativeAotToolsTests(SampleAssemblyFixture samples) : McpServerTestBase +[TestClass] +public class NativeAotToolsTests : McpServerTestBase { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// get_native_aot_info reports Native AOT identity and sidecar availability. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetNativeAotInfo_NativeAot_ReturnsSummary() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); await StartServerAsync(); await using var client = await CreateClientAsync(); var result = await client.CallToolAsync( "get_native_aot_info", - new Dictionary { ["assemblyPath"] = samples.NativeAotConsoleExe }, + new Dictionary { ["assemblyPath"] = Samples.NativeAotConsoleExe }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.Equal("nativeAot", json.GetProperty("binaryKind").GetString()); - Assert.True(json.GetProperty("readyToRunSections").GetInt32() > 0); - Assert.True(json.GetProperty("recoveredTypes").GetInt32() > 0); - Assert.True(json.TryGetProperty("hasMstat", out _)); + Assert.AreEqual("nativeAot", json.GetProperty("binaryKind").GetString()); + Assert.IsGreaterThan(0, json.GetProperty("readyToRunSections").GetInt32()); + Assert.IsGreaterThan(0, json.GetProperty("recoveredTypes").GetInt32()); + Assert.IsTrue(json.TryGetProperty("hasMstat", out _)); } /// list_native_aot_sections returns Native AOT RTR module sections. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ListNativeAotSections_NativeAot_ReturnsRtrSections() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); await StartServerAsync(); await using var client = await CreateClientAsync(); var result = await client.CallToolAsync( "list_native_aot_sections", - new Dictionary { ["assemblyPath"] = samples.NativeAotConsoleExe }, + new Dictionary { ["assemblyPath"] = Samples.NativeAotConsoleExe }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.True(json.GetProperty("sectionCount").GetInt32() > 0); + Assert.IsGreaterThan(0, json.GetProperty("sectionCount").GetInt32()); var first = json.GetProperty("sections").EnumerateArray().First(); - Assert.True(first.TryGetProperty("sectionId", out _)); - Assert.True(first.TryGetProperty("address", out _)); + Assert.IsTrue(first.TryGetProperty("sectionId", out _)); + Assert.IsTrue(first.TryGetProperty("address", out _)); } /// get_native_aot_size_contributors returns normalized mstat contributors. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetNativeAotSizeContributors_NativeAot_ReturnsTopMstatRows() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); await StartServerAsync(); await using var client = await CreateClientAsync(); @@ -67,30 +72,31 @@ public async Task GetNativeAotSizeContributors_NativeAot_ReturnsTopMstatRows() "get_native_aot_size_contributors", new Dictionary { - ["assemblyPath"] = samples.NativeAotConsoleExe, + ["assemblyPath"] = Samples.NativeAotConsoleExe, ["section"] = "Method", ["topN"] = 5 }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.True(json.GetProperty("totalMatches").GetInt32() > 0); + Assert.IsGreaterThan(0, json.GetProperty("totalMatches").GetInt32()); var rows = json.GetProperty("contributors"); - Assert.True(rows.GetArrayLength() > 0); - Assert.All(rows.EnumerateArray(), row => + Assert.IsGreaterThan(0, rows.GetArrayLength()); + TestAssert.All(rows.EnumerateArray(), row => { - Assert.Equal("method", row.GetProperty("section").GetString()); - Assert.True(row.GetProperty("size").GetInt64() > 0); + Assert.AreEqual("method", row.GetProperty("section").GetString()); + Assert.IsGreaterThan(0, row.GetProperty("size").GetInt64()); }); } /// explain_native_aot_size returns a DGML root chain for a real contributor. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ExplainNativeAotSize_NativeAot_ReturnsRootChain() { - Assert.SkipWhen(samples.NativeAotConsoleDgml is null, "DGML sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleDgml is null, "DGML sidecar was not produced"); await StartServerAsync(); await using var client = await CreateClientAsync(); @@ -99,36 +105,36 @@ public async Task ExplainNativeAotSize_NativeAot_ReturnsRootChain() "get_native_aot_size_contributors", new Dictionary { - ["assemblyPath"] = samples.NativeAotConsoleExe, + ["assemblyPath"] = Samples.NativeAotConsoleExe, ["section"] = "Method", ["topN"] = 20 }, cancellationToken: TestCancellationToken); var contributorText = GetTextContent(contributors); - Assert.NotNull(contributorText); + Assert.IsNotNull(contributorText); var contributorJson = JsonSerializer.Deserialize(contributorText); var target = contributorJson.GetProperty("contributors") .EnumerateArray() .First(e => e.GetProperty("nodeNames").GetArrayLength() > 0) .GetProperty("fullPath") .GetString(); - Assert.NotNull(target); + Assert.IsNotNull(target); var result = await client.CallToolAsync( "explain_native_aot_size", new Dictionary { - ["assemblyPath"] = samples.NativeAotConsoleExe, + ["assemblyPath"] = Samples.NativeAotConsoleExe, ["target"] = target }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.Equal("resolved", json.GetProperty("outcome").GetString()); + Assert.AreEqual("resolved", json.GetProperty("outcome").GetString()); var chains = json.GetProperty("contributor").GetProperty("whyChains"); - Assert.True(chains.GetArrayLength() > 0); + Assert.IsGreaterThan(0, chains.GetArrayLength()); } } diff --git a/tests/Dotsider.Mcp.Tests/NavigationToolsTests.cs b/tests/Dotsider.Mcp.Tests/NavigationToolsTests.cs index 871432a2..85eddd4c 100644 --- a/tests/Dotsider.Mcp.Tests/NavigationToolsTests.cs +++ b/tests/Dotsider.Mcp.Tests/NavigationToolsTests.cs @@ -11,9 +11,11 @@ namespace Dotsider.Mcp.Tests; /// wired to real analyzers. These exercise the actual currentViewProvider and /// getState paths that regressed in diff/nuget modes. /// -[Collection("SampleAssemblies")] -public class NavigationToolsTests(SampleAssemblyFixture samples) : McpServerTestBase +[TestClass] +public class NavigationToolsTests : McpServerTestBase { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private readonly List _disposables = []; // --- Diff mode: real listener with real AssemblyAnalyzers --- @@ -21,14 +23,14 @@ public class NavigationToolsTests(SampleAssemblyFixture samples) : McpServerTest /// /// Diff-mode sessions expose both the active tab and the diff filter mode via get_current_view. /// - [Fact] + [TestMethod] public async Task GetCurrentView_DiffMode_ReturnsTabAndFilterMode() { var currentTab = 2; var filterMode = DiffFilterMode.AddedOnly; await using var listener = CreateDiffListener( - samples.RichLibraryDll, samples.RichLibraryV2Dll, + Samples.RichLibraryDll, Samples.RichLibraryV2Dll, () => currentTab, () => filterMode); await StartServerAsync(); @@ -40,21 +42,21 @@ public async Task GetCurrentView_DiffMode_ReturnsTabAndFilterMode() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var doc = JsonDocument.Parse(text!); - Assert.Equal("diff", doc.RootElement.GetProperty("mode").GetString()); - Assert.Equal(3, doc.RootElement.GetProperty("tab").GetInt32()); - Assert.Equal("addedOnly", doc.RootElement.GetProperty("filterMode").GetString()); + Assert.AreEqual("diff", doc.RootElement.GetProperty("mode").GetString()); + Assert.AreEqual(3, doc.RootElement.GetProperty("tab").GetInt32()); + Assert.AreEqual("addedOnly", doc.RootElement.GetProperty("filterMode").GetString()); } /// /// When browsing a nupkg without a selected DLL, isBrowsingPackage is reported as true. /// - [Fact] + [TestMethod] public async Task GetCurrentView_NugetMode_BrowsingPackage() { - await using var listener = CreateNugetListener(samples.RichLibraryNupkg); + await using var listener = CreateNugetListener(Samples.RichLibraryNupkg); await StartServerAsync(); await using var client = await CreateClientAsync(); @@ -65,21 +67,21 @@ public async Task GetCurrentView_NugetMode_BrowsingPackage() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var doc = JsonDocument.Parse(text!); - Assert.Equal("nuget", doc.RootElement.GetProperty("mode").GetString()); - Assert.True(doc.RootElement.GetProperty("isBrowsingPackage").GetBoolean()); + Assert.AreEqual("nuget", doc.RootElement.GetProperty("mode").GetString()); + Assert.IsTrue(doc.RootElement.GetProperty("isBrowsingPackage").GetBoolean()); } /// /// Once a DLL is selected inside a nupkg, isBrowsingPackage flips false and the tab is reported. /// - [Fact] + [TestMethod] public async Task GetCurrentView_NugetMode_DllSelected() { await using var listener = CreateNugetListener( - samples.RichLibraryNupkg, + Samples.RichLibraryNupkg, selectDll: true, selectedDllTab: 3); await StartServerAsync(); @@ -91,22 +93,22 @@ public async Task GetCurrentView_NugetMode_DllSelected() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var doc = JsonDocument.Parse(text!); - Assert.Equal("nuget", doc.RootElement.GetProperty("mode").GetString()); - Assert.False(doc.RootElement.GetProperty("isBrowsingPackage").GetBoolean()); - Assert.Equal(4, doc.RootElement.GetProperty("tab").GetInt32()); + Assert.AreEqual("nuget", doc.RootElement.GetProperty("mode").GetString()); + Assert.IsFalse(doc.RootElement.GetProperty("isBrowsingPackage").GetBoolean()); + Assert.AreEqual(4, doc.RootElement.GetProperty("tab").GetInt32()); } /// /// Diff mode has no single DotsiderState, so navigate_to fails with a clear message. /// - [Fact] + [TestMethod] public async Task NavigateTo_DiffMode_FailsBecauseNoState() { await using var listener = CreateDiffListener( - samples.RichLibraryDll, samples.RichLibraryV2Dll, + Samples.RichLibraryDll, Samples.RichLibraryV2Dll, () => 0, () => DiffFilterMode.All); await StartServerAsync(); @@ -119,17 +121,17 @@ public async Task NavigateTo_DiffMode_FailsBecauseNoState() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains("No assembly is loaded", text); } /// /// Without a selected DLL, NuGet mode cannot satisfy navigate_to and returns an error. /// - [Fact] + [TestMethod] public async Task NavigateTo_NugetMode_NoDllSelected_Fails() { - await using var listener = CreateNugetListener(samples.RichLibraryNupkg); + await using var listener = CreateNugetListener(Samples.RichLibraryNupkg); await StartServerAsync(); await using var client = await CreateClientAsync(); @@ -140,7 +142,7 @@ public async Task NavigateTo_NugetMode_NoDllSelected_Fails() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains("No assembly is loaded", text); } @@ -149,14 +151,14 @@ public async Task NavigateTo_NugetMode_NoDllSelected_Fails() /// /// Against a live headless NuGet TUI, navigate_to updates the current tab and get_current_view reflects it. /// - [Fact] + [TestMethod] public async Task NavigateTo_LiveNuget_OpenedDll_ChangesTabAndVerifiesView() { var ct = TestCancellationToken; // Start a headless NuGet TUI with a real listener, exactly like Program.RunTui var (app, nugetState, listener) = await StartLiveNugetTuiAsync( - samples.RichLibraryNupkg, ct); + Samples.RichLibraryNupkg, ct); _disposables.Add(listener); // Open the first DLL in the package @@ -167,7 +169,7 @@ public async Task NavigateTo_LiveNuget_OpenedDll_ChangesTabAndVerifiesView() nugetState.IsBrowsingPackage = false; app.Invalidate(); - Assert.Equal(TabId.General, nugetState.SelectedDllState.CurrentTab); + Assert.AreEqual(TabId.General, nugetState.SelectedDllState.CurrentTab); await StartServerAsync(); await using var client = await CreateClientAsync(); @@ -182,7 +184,7 @@ public async Task NavigateTo_LiveNuget_OpenedDll_ChangesTabAndVerifiesView() }, cancellationToken: ct); var navText = GetTextContent(navResult); - Assert.NotNull(navText); + Assert.IsNotNull(navText); Assert.DoesNotContain("Error", navText); // Wait for the render loop to drain the mutation queue @@ -190,7 +192,7 @@ await WaitUntilAsync( () => nugetState.SelectedDllState!.CurrentTab == TabId.Strings, TimeSpan.FromSeconds(5)); - Assert.Equal(TabId.Strings, nugetState.SelectedDllState.CurrentTab); + Assert.AreEqual(TabId.Strings, nugetState.SelectedDllState.CurrentTab); // Verify get_current_view reflects the navigated tab var viewResult = await client.CallToolAsync( @@ -198,12 +200,12 @@ await WaitUntilAsync( new Dictionary { ["sessionId"] = listener.Pid }, cancellationToken: ct); var viewText = GetTextContent(viewResult); - Assert.NotNull(viewText); + Assert.IsNotNull(viewText); var doc = JsonDocument.Parse(viewText!); - Assert.Equal("nuget", doc.RootElement.GetProperty("mode").GetString()); - Assert.False(doc.RootElement.GetProperty("isBrowsingPackage").GetBoolean()); - Assert.Equal(TabId.Strings + 1, doc.RootElement.GetProperty("tab").GetInt32()); + Assert.AreEqual("nuget", doc.RootElement.GetProperty("mode").GetString()); + Assert.IsFalse(doc.RootElement.GetProperty("isBrowsingPackage").GetBoolean()); + Assert.AreEqual(TabId.Strings + 1, doc.RootElement.GetProperty("tab").GetInt32()); } // --- Disposal --- @@ -214,7 +216,6 @@ await WaitUntilAsync( public override async ValueTask DisposeAsync() { GC.SuppressFinalize(this); - foreach (var d in _disposables) await d.DisposeAsync(); _disposables.Clear(); @@ -409,6 +410,7 @@ private sealed class RealListenerHandle( public async ValueTask DisposeAsync() { + GC.SuppressFinalize(this); await listener.DisposeAsync(); foreach (var d in owned) d.Dispose(); diff --git a/tests/Dotsider.Mcp.Tests/NuGetToolsTests.cs b/tests/Dotsider.Mcp.Tests/NuGetToolsTests.cs index 850bb9d8..adc6ff35 100644 --- a/tests/Dotsider.Mcp.Tests/NuGetToolsTests.cs +++ b/tests/Dotsider.Mcp.Tests/NuGetToolsTests.cs @@ -8,13 +8,15 @@ namespace Dotsider.Mcp.Tests; /// /// Creates the tests using the shared sample assembly fixture. /// -[Collection("SampleAssemblies")] -public class NuGetToolsTests(SampleAssemblyFixture samples) : McpServerTestBase +[TestClass] +public class NuGetToolsTests : McpServerTestBase { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// analyze_nupkg surfaces packageId and packageVersion from the .nuspec manifest. /// - [Fact] + [TestMethod] public async Task AnalyzeNupkg_RichLibrary_ReturnsPackageMetadata() { await StartServerAsync(); @@ -22,20 +24,20 @@ public async Task AnalyzeNupkg_RichLibrary_ReturnsPackageMetadata() var result = await client.CallToolAsync( "analyze_nupkg", - new Dictionary { ["nupkgPath"] = samples.RichLibraryNupkg }, + new Dictionary { ["nupkgPath"] = Samples.RichLibraryNupkg }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.Equal("RichLibrary", json.GetProperty("packageId").GetString()); - Assert.Equal("2.5.1", json.GetProperty("packageVersion").GetString()); + Assert.AreEqual("RichLibrary", json.GetProperty("packageId").GetString()); + Assert.AreEqual("2.5.1", json.GetProperty("packageVersion").GetString()); } /// /// analyze_nupkg enumerates the DLLs bundled in the lib/ folders of the package. /// - [Fact] + [TestMethod] public async Task AnalyzeNupkg_RichLibrary_ListsDlls() { await StartServerAsync(); @@ -43,13 +45,13 @@ public async Task AnalyzeNupkg_RichLibrary_ListsDlls() var result = await client.CallToolAsync( "analyze_nupkg", - new Dictionary { ["nupkgPath"] = samples.RichLibraryNupkg }, + new Dictionary { ["nupkgPath"] = Samples.RichLibraryNupkg }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.True(json.TryGetProperty("dllFiles", out var dlls)); - Assert.True(dlls.GetArrayLength() > 0); + Assert.IsTrue(json.TryGetProperty("dllFiles", out var dlls)); + Assert.IsGreaterThan(0, dlls.GetArrayLength()); } } diff --git a/tests/Dotsider.Mcp.Tests/PromptTests.cs b/tests/Dotsider.Mcp.Tests/PromptTests.cs index 1c112cff..42877913 100644 --- a/tests/Dotsider.Mcp.Tests/PromptTests.cs +++ b/tests/Dotsider.Mcp.Tests/PromptTests.cs @@ -3,12 +3,13 @@ namespace Dotsider.Mcp.Tests; /// /// Tests for the MCP prompts catalog and parameterized prompt retrieval. /// +[TestClass] public class PromptTests : McpServerTestBase { /// /// ListPrompts returns every prompt the server advertises by canonical name. /// - [Fact] + [TestMethod] public async Task ListPrompts_ReturnsAllRegisteredPrompts() { await StartServerAsync(); @@ -16,7 +17,7 @@ public async Task ListPrompts_ReturnsAllRegisteredPrompts() var prompts = await client.ListPromptsAsync(cancellationToken: TestCancellationToken); - Assert.True(prompts.Count >= 4); + Assert.IsGreaterThanOrEqualTo(4, prompts.Count); var names = prompts.Select(p => p.Name).ToList(); Assert.Contains("security_audit", names); Assert.Contains("api_review", names); @@ -27,19 +28,20 @@ public async Task ListPrompts_ReturnsAllRegisteredPrompts() /// /// The prompt catalog includes the bundle_analysis prompt registered alongside bundle tools. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ListPrompts_IncludesBundleAnalysis() { await StartServerAsync(); await using var client = await CreateClientAsync(); var prompts = await client.ListPromptsAsync(cancellationToken: TestCancellationToken); - Assert.Contains(prompts, p => p.Name == "bundle_analysis"); + Assert.Contains(p => p.Name == "bundle_analysis", prompts); } /// /// security_audit materializes at least one message when given an assembly path. /// - [Fact] + [TestMethod] public async Task GetPrompt_SecurityAudit_ReturnsPromptContent() { await StartServerAsync(); @@ -50,14 +52,14 @@ public async Task GetPrompt_SecurityAudit_ReturnsPromptContent() new Dictionary { ["assemblyPath"] = "/test/path.dll" }, cancellationToken: TestCancellationToken); - Assert.NotNull(result); - Assert.True(result.Messages.Count > 0); + Assert.IsNotNull(result); + Assert.IsGreaterThan(0, result.Messages.Count); } /// /// breaking_change_detection accepts both old and new assembly paths and produces messages. /// - [Fact] + [TestMethod] public async Task GetPrompt_BreakingChangeDetection_AcceptsTwoPaths() { await StartServerAsync(); @@ -72,8 +74,8 @@ public async Task GetPrompt_BreakingChangeDetection_AcceptsTwoPaths() }, cancellationToken: TestCancellationToken); - Assert.NotNull(result); - Assert.True(result.Messages.Count > 0); + Assert.IsNotNull(result); + Assert.IsGreaterThan(0, result.Messages.Count); } /// @@ -83,7 +85,7 @@ public async Task GetPrompt_BreakingChangeDetection_AcceptsTwoPaths() /// models don't expect the tool to filter them. Asserting content (not just registration) /// catches future prompt/tool contract drift that was the original issue behind #149. /// - [Fact] + [TestMethod] public async Task GetPrompt_DependencyHealth_ContentMentionsTransitiveClosureAndFrameworkInclusion() { await StartServerAsync(); @@ -94,7 +96,7 @@ public async Task GetPrompt_DependencyHealth_ContentMentionsTransitiveClosureAnd new Dictionary { ["assemblyPath"] = "/test/path.dll" }, cancellationToken: TestCancellationToken); - Assert.NotNull(result); + Assert.IsNotNull(result); var content = string.Join("\n", result.Messages.Select(m => m.Content.ToString())); Assert.Contains("transitive", content, StringComparison.OrdinalIgnoreCase); Assert.Contains("diamond", content, StringComparison.OrdinalIgnoreCase); diff --git a/tests/Dotsider.Mcp.Tests/ReadyToRunToolsTests.cs b/tests/Dotsider.Mcp.Tests/ReadyToRunToolsTests.cs index 2d434811..32b2f3c1 100644 --- a/tests/Dotsider.Mcp.Tests/ReadyToRunToolsTests.cs +++ b/tests/Dotsider.Mcp.Tests/ReadyToRunToolsTests.cs @@ -6,16 +6,19 @@ namespace Dotsider.Mcp.Tests; /// ambiguous-name error path) and get_native_disassembly made R2R-method-aware — a multi-range /// method resolves to the method and renders all its ranges rather than a false per-range ambiguity. /// -[Collection("SampleAssemblies")] -public class ReadyToRunToolsTests(SampleAssemblyFixture samples) : McpServerTestBase +[TestClass] +public class ReadyToRunToolsTests : McpServerTestBase { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private const string SkipReason = "ReadyToRun crossgen2 publish did not run on this leg."; /// correlate_r2r_method resolves a unique method to its report with IL and native code. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task CorrelateR2rMethod_ByName_ReturnsReport() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); await StartServerAsync(); await using var client = await CreateClientAsync(); @@ -25,13 +28,13 @@ public async Task CorrelateR2rMethod_ByName_ReturnsReport() new Dictionary { ["methodOrAddress"] = "Greeter.get_Name", - ["assemblyPath"] = samples.ReadyToRunConsoleDll, + ["assemblyPath"] = Samples.ReadyToRunConsoleDll, }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); - Assert.False(text!.StartsWith("Error", StringComparison.Ordinal), "the tool reported an error"); + Assert.IsNotNull(text); + Assert.IsFalse(text!.StartsWith("Error", StringComparison.Ordinal), "the tool reported an error"); Assert.Contains("get_Name", text); // The JSON report carries the honest native-availability state (camelCase enum) and the IL. Assert.Contains("precompiled", text, StringComparison.OrdinalIgnoreCase); @@ -39,10 +42,11 @@ public async Task CorrelateR2rMethod_ByName_ReturnsReport() } /// correlate_r2r_method reports an overloaded name as an ambiguity, never first-match. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task CorrelateR2rMethod_Overloaded_ReturnsAmbiguity() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); await StartServerAsync(); await using var client = await CreateClientAsync(); @@ -52,20 +56,21 @@ public async Task CorrelateR2rMethod_Overloaded_ReturnsAmbiguity() new Dictionary { ["methodOrAddress"] = "Greet", - ["assemblyPath"] = samples.ReadyToRunConsoleDll, + ["assemblyPath"] = Samples.ReadyToRunConsoleDll, }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains("ambiguous", text!.ToLowerInvariant()); } /// get_native_disassembly renders every range of a multi-range R2R method (no false ambiguity). - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetNativeDisassembly_ReadyToRun_MultiRange_RendersAllRanges() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); await StartServerAsync(); await using var client = await CreateClientAsync(); @@ -75,12 +80,12 @@ public async Task GetNativeDisassembly_ReadyToRun_MultiRange_RendersAllRanges() new Dictionary { ["symbolName"] = "MoveNext", - ["assemblyPath"] = samples.ReadyToRunConsoleDll, + ["assemblyPath"] = Samples.ReadyToRunConsoleDll, }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); // Not an ambiguity error, and the import resolver named a cross-call target in the body. Assert.DoesNotContain("ambiguous", text!.ToLowerInvariant()); Assert.Contains("WriteLine", text); diff --git a/tests/Dotsider.Mcp.Tests/RemoteDotsiderTargetVersionTests.cs b/tests/Dotsider.Mcp.Tests/RemoteDotsiderTargetVersionTests.cs index fc33f2c4..b17e7e49 100644 --- a/tests/Dotsider.Mcp.Tests/RemoteDotsiderTargetVersionTests.cs +++ b/tests/Dotsider.Mcp.Tests/RemoteDotsiderTargetVersionTests.cs @@ -6,12 +6,14 @@ namespace Dotsider.Mcp.Tests; /// Tests that correctly rejects old or /// mismatched server responses. /// +[TestClass] public class RemoteDotsiderTargetVersionTests { /// /// A pre-versioned server response is rejected because the protocol contract requires a 'v' field. /// - [Fact(Timeout = 10_000)] + [TestMethod] + [Timeout(10_000, CooperativeCancellation = true)] public async Task RejectsOldServerResponse() { var socketPath = GetUniqueSocketPath(); @@ -22,16 +24,17 @@ public async Task RejectsOldServerResponse() var target = new RemoteDotsiderTarget(socketPath); var response = await target.SendAsync( new DotsiderRequest { Method = "assembly-info" }, - TestContext.Current.CancellationToken); + CancellationToken.None); - Assert.False(response.Success); + Assert.IsFalse(response.Success); Assert.Contains("server response", response.Error!, StringComparison.OrdinalIgnoreCase); } /// /// Rejects a remote server whose advertised version differs from the client target. /// - [Fact(Timeout = 10_000)] + [TestMethod] + [Timeout(10_000, CooperativeCancellation = true)] public async Task RejectsWrongServerVersion() { var socketPath = GetUniqueSocketPath(); @@ -42,9 +45,9 @@ public async Task RejectsWrongServerVersion() var target = new RemoteDotsiderTarget(socketPath); var response = await target.SendAsync( new DotsiderRequest { Method = "assembly-info" }, - TestContext.Current.CancellationToken); + CancellationToken.None); - Assert.False(response.Success); + Assert.IsFalse(response.Success); Assert.Contains("version mismatch", response.Error!, StringComparison.OrdinalIgnoreCase); } @@ -54,6 +57,6 @@ private static string GetUniqueSocketPath() Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".dotsider", "sockets"); Directory.CreateDirectory(dir); - return Path.Combine(dir, $"test-{Random.Shared.Next(100_000, 999_999)}.dotsider.socket"); + return Path.Combine(dir, $"test-{TestSocketIds.NextPid()}.dotsider.socket"); } } diff --git a/tests/Dotsider.Mcp.Tests/RuntimeToolsTests.cs b/tests/Dotsider.Mcp.Tests/RuntimeToolsTests.cs index fd30c8ff..3d549f48 100644 --- a/tests/Dotsider.Mcp.Tests/RuntimeToolsTests.cs +++ b/tests/Dotsider.Mcp.Tests/RuntimeToolsTests.cs @@ -5,13 +5,16 @@ namespace Dotsider.Mcp.Tests; /// /// Tests for runtime discovery and assembly resolution MCP tools. /// -[Collection("SampleAssemblies")] -public class RuntimeToolsTests(SampleAssemblyFixture samples) : McpServerTestBase +[TestClass] +public class RuntimeToolsTests : McpServerTestBase { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// find_framework_assembly resolves System.Runtime to a path inside the shared framework pack. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task FindFrameworkAssembly_SystemRuntime_ReturnsPathAndPack() { await StartServerAsync(); @@ -22,16 +25,17 @@ public async Task FindFrameworkAssembly_SystemRuntime_ReturnsPathAndPack() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.NotEmpty(json.GetProperty("path").GetString()!); - Assert.Equal("Microsoft.NETCore.App", json.GetProperty("runtimePack").GetString()); + Assert.IsNotEmpty(json.GetProperty("path").GetString()!); + Assert.AreEqual("Microsoft.NETCore.App", json.GetProperty("runtimePack").GetString()); } /// /// Unknown framework assembly names yield a serialized null rather than an error. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task FindFrameworkAssembly_Nonexistent_ReturnsNull() { await StartServerAsync(); @@ -42,14 +46,15 @@ public async Task FindFrameworkAssembly_Nonexistent_ReturnsNull() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); - Assert.Equal("null", text); + Assert.IsNotNull(text); + Assert.AreEqual("null", text); } /// /// resolve_assembly routes a shared framework dependency to a file-kind resolution. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ResolveAssembly_Direct_SharedFramework() { await StartServerAsync(); @@ -59,20 +64,21 @@ public async Task ResolveAssembly_Direct_SharedFramework() new Dictionary { ["assemblyName"] = "System.Runtime", - ["assemblyPath"] = samples.RichLibraryDll + ["assemblyPath"] = Samples.RichLibraryDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.Equal("file", json.GetProperty("kind").GetString()); + Assert.AreEqual("file", json.GetProperty("kind").GetString()); } /// /// Tool registry advertises the two runtime discovery tools by canonical name. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ListTools_IncludesRuntimeTools() { await StartServerAsync(); diff --git a/tests/Dotsider.Mcp.Tests/SampleAssemblyCollection.cs b/tests/Dotsider.Mcp.Tests/SampleAssemblyCollection.cs deleted file mode 100644 index 7f3e2301..00000000 --- a/tests/Dotsider.Mcp.Tests/SampleAssemblyCollection.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Dotsider.Mcp.Tests; - -/// -/// Collection definition that binds to tests opting into the shared fixture. -/// -[CollectionDefinition("SampleAssemblies")] -public class SampleAssemblyCollection : ICollectionFixture; diff --git a/tests/Dotsider.Mcp.Tests/SampleAssemblyFixture.cs b/tests/Dotsider.Mcp.Tests/SampleAssemblyFixture.cs index d7f05c95..601bdaa9 100644 --- a/tests/Dotsider.Mcp.Tests/SampleAssemblyFixture.cs +++ b/tests/Dotsider.Mcp.Tests/SampleAssemblyFixture.cs @@ -4,9 +4,9 @@ namespace Dotsider.Mcp.Tests; /// -/// Shared xUnit fixture that builds and exposes sample assemblies used across MCP tool tests. +/// Shared MSTest fixture that builds and exposes sample assemblies used across MCP tool tests. /// -public class SampleAssemblyFixture : IAsyncLifetime +internal class SampleAssemblyFixture : IAsyncDisposable { private string _repoRoot = null!; @@ -177,12 +177,12 @@ public async ValueTask InitializeAsync() "bin", "Release", tfm, "browser-wasm", "AppBundle", "_framework", "WasmConsole.wasm"); WasmConsoleWebcilWasm = File.Exists(wasmConsoleWebcil) ? wasmConsoleWebcil : null; - Assert.True(File.Exists(HelloWorldDll), $"HelloWorld.dll not found at {HelloWorldDll}"); - Assert.True(File.Exists(HelloWorldExe), $"HelloWorld apphost not found at {HelloWorldExe}"); - Assert.True(File.Exists(RichLibraryDll), $"RichLibrary.dll not found at {RichLibraryDll}"); - Assert.True(File.Exists(RichLibraryNupkg), $"RichLibrary.nupkg not found at {RichLibraryNupkg}"); - Assert.True(File.Exists(MinimalApiDll), $"MinimalApi.dll not found at {MinimalApiDll}"); - Assert.True(File.Exists(SelfContainedConsoleExe), $"SelfContainedConsole not found at {SelfContainedConsoleExe}"); + Assert.IsTrue(File.Exists(HelloWorldDll), $"HelloWorld.dll not found at {HelloWorldDll}"); + Assert.IsTrue(File.Exists(HelloWorldExe), $"HelloWorld apphost not found at {HelloWorldExe}"); + Assert.IsTrue(File.Exists(RichLibraryDll), $"RichLibrary.dll not found at {RichLibraryDll}"); + Assert.IsTrue(File.Exists(RichLibraryNupkg), $"RichLibrary.nupkg not found at {RichLibraryNupkg}"); + Assert.IsTrue(File.Exists(MinimalApiDll), $"MinimalApi.dll not found at {MinimalApiDll}"); + Assert.IsTrue(File.Exists(SelfContainedConsoleExe), $"SelfContainedConsole not found at {SelfContainedConsoleExe}"); if (!File.Exists(NativeAotConsoleExe)) NativeAotConsoleExe = null; @@ -276,6 +276,7 @@ private async Task BuildProject(string relativePath) RedirectStandardOutput = true, RedirectStandardError = true, }; + TestProcessEnvironment.RemoveCodeCoverageVariables(psi); var process = Process.Start(psi)!; var stdout = await process.StandardOutput.ReadToEndAsync(); @@ -338,6 +339,7 @@ private async Task PublishSelfContainedProject(string relativePath) WorkingDirectory = projectDir, UseShellExecute = false, }; + TestProcessEnvironment.RemoveCodeCoverageVariables(psi); var process = Process.Start(psi)!; await process.WaitForExitAsync(); @@ -390,6 +392,7 @@ private async Task PublishReadyToRunProject(string relativePath) RedirectStandardOutput = true, RedirectStandardError = true, }; + TestProcessEnvironment.RemoveCodeCoverageVariables(psi); var process = Process.Start(psi)!; _ = await process.StandardOutput.ReadToEndAsync(); _ = await process.StandardError.ReadToEndAsync(); @@ -445,6 +448,7 @@ private async Task PublishWasmProject(string relativePath) RedirectStandardOutput = true, RedirectStandardError = true, }; + TestProcessEnvironment.RemoveCodeCoverageVariables(psi); var process = Process.Start(psi)!; _ = await process.StandardOutput.ReadToEndAsync(); _ = await process.StandardError.ReadToEndAsync(); @@ -513,6 +517,7 @@ private async Task PublishNativeAotProject(string relativePath) // is not resolved from the current directory inside FOR /F). // Clear it for this process only. psi.Environment.Remove("NoDefaultCurrentDirectoryInExePath"); + TestProcessEnvironment.RemoveCodeCoverageVariables(psi); var process = Process.Start(psi)!; await process.WaitForExitAsync(); diff --git a/tests/Dotsider.Mcp.Tests/SampleAssemblyHost.cs b/tests/Dotsider.Mcp.Tests/SampleAssemblyHost.cs new file mode 100644 index 00000000..6ded0471 --- /dev/null +++ b/tests/Dotsider.Mcp.Tests/SampleAssemblyHost.cs @@ -0,0 +1,31 @@ +namespace Dotsider.Mcp.Tests; + +/// +/// MSTest assembly fixture that builds shared sample assemblies once for this test assembly. +/// +[TestClass] +public static class SampleAssemblyHost +{ + internal static SampleAssemblyFixture Instance { get; private set; } = null!; + + /// + /// Initializes shared sample assemblies before the test assembly runs. + /// + /// The MSTest assembly initialization context. + [AssemblyInitialize] + public static async Task AssemblyInitialize(TestContext context) + { + Instance = new SampleAssemblyFixture(); + await Instance.InitializeAsync(); + } + + /// + /// Cleans up shared sample assemblies after the test assembly completes. + /// + [AssemblyCleanup] + public static async Task AssemblyCleanup() + { + if (Instance is not null) + await Instance.DisposeAsync(); + } +} diff --git a/tests/Dotsider.Mcp.Tests/SessionToolsTests.cs b/tests/Dotsider.Mcp.Tests/SessionToolsTests.cs index 66069e2d..fb163bb4 100644 --- a/tests/Dotsider.Mcp.Tests/SessionToolsTests.cs +++ b/tests/Dotsider.Mcp.Tests/SessionToolsTests.cs @@ -11,16 +11,18 @@ namespace Dotsider.Mcp.Tests; /// /// Creates the tests using the shared sample assembly fixture. /// -[Collection("SampleAssemblies")] -public class SessionToolsTests(SampleAssemblyFixture samples) : McpServerTestBase +[TestClass] +public class SessionToolsTests : McpServerTestBase { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + // Use PIDs that won't collide with real processes or other test classes private static int s_nextPid = 999_700; /// /// discover_dotsider_sessions picks up a simulated listening instance by PID. /// - [Fact] + [TestMethod] public async Task DiscoverDotsiderSessions_FindsRunningInstance() { await using var socket = new TestDotsiderSocket(999_999, "/tmp/test/HelloWorld.dll"); @@ -33,22 +35,22 @@ public async Task DiscoverDotsiderSessions_FindsRunningInstance() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); // Should find our test instance in the JSON array var sessions = JsonSerializer.Deserialize(text); - Assert.NotNull(sessions); + Assert.IsNotNull(sessions); var testSession = sessions!.FirstOrDefault(s => s.GetProperty("pid").GetInt32() == 999_999); - Assert.NotEqual(default, testSession); - Assert.Equal(999_999, testSession.GetProperty("pid").GetInt32()); + Assert.AreNotEqual(default, testSession); + Assert.AreEqual(999_999, testSession.GetProperty("pid").GetInt32()); } /// /// get_session_info combines the remote assembly-info and current-view payloads into one response. /// - [Fact] + [TestMethod] public async Task GetSessionInfo_ReturnsAssemblyAndViewData() { await using var socket = new TestDotsiderSocket(999_998, "/tmp/test/HelloWorld.dll"); @@ -69,11 +71,11 @@ public async Task GetSessionInfo_ReturnsAssemblyAndViewData() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var doc = JsonDocument.Parse(text!); - Assert.True(doc.RootElement.TryGetProperty("assembly", out _)); - Assert.True(doc.RootElement.TryGetProperty("view", out _)); + Assert.IsTrue(doc.RootElement.TryGetProperty("assembly", out _)); + Assert.IsTrue(doc.RootElement.TryGetProperty("view", out _)); } // --- Diff mode: real DotsiderDiagnosticsListener with real AssemblyAnalyzers --- @@ -81,7 +83,7 @@ public async Task GetSessionInfo_ReturnsAssemblyAndViewData() /// /// Discovery surfaces a real diff-mode listener and exposes both left/right assembly metadata. /// - [Fact] + [TestMethod] public async Task DiscoverDotsiderSessions_FindsDiffModeInstance() { var (pid, listener, analyzers) = CreateRealDiffListener(); @@ -97,23 +99,23 @@ public async Task DiscoverDotsiderSessions_FindsDiffModeInstance() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var sessions = JsonSerializer.Deserialize(text); - Assert.NotNull(sessions); + Assert.IsNotNull(sessions); var diffSession = sessions!.FirstOrDefault(s => s.GetProperty("pid").GetInt32() == pid); - Assert.NotEqual(default, diffSession); - Assert.Equal("diff", diffSession.GetProperty("info").GetProperty("mode").GetString()); - Assert.True(diffSession.GetProperty("info").TryGetProperty("left", out _)); - Assert.True(diffSession.GetProperty("info").TryGetProperty("right", out _)); + Assert.AreNotEqual(default, diffSession); + Assert.AreEqual("diff", diffSession.GetProperty("info").GetProperty("mode").GetString()); + Assert.IsTrue(diffSession.GetProperty("info").TryGetProperty("left", out _)); + Assert.IsTrue(diffSession.GetProperty("info").TryGetProperty("right", out _)); } /// /// Diff-mode session info carries left/right assembly names plus the current tab and filter mode. /// - [Fact] + [TestMethod] public async Task GetSessionInfo_DiffMode_ReturnsBothAssemblyAndView() { var (pid, listener, analyzers) = CreateRealDiffListener(); @@ -129,21 +131,21 @@ public async Task GetSessionInfo_DiffMode_ReturnsBothAssemblyAndView() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var doc = JsonDocument.Parse(text!); // Assembly info from the real listener's assemblyInfoProvider var assembly = doc.RootElement.GetProperty("assembly"); - Assert.Equal("diff", assembly.GetProperty("mode").GetString()); - Assert.Equal("RichLibrary", assembly.GetProperty("left").GetProperty("assemblyName").GetString()); - Assert.Equal("RichLibrary", assembly.GetProperty("right").GetProperty("assemblyName").GetString()); + Assert.AreEqual("diff", assembly.GetProperty("mode").GetString()); + Assert.AreEqual("RichLibrary", assembly.GetProperty("left").GetProperty("assemblyName").GetString()); + Assert.AreEqual("RichLibrary", assembly.GetProperty("right").GetProperty("assemblyName").GetString()); // View from the real listener's currentViewProvider var view = doc.RootElement.GetProperty("view"); - Assert.Equal("diff", view.GetProperty("mode").GetString()); - Assert.True(view.TryGetProperty("tab", out _)); - Assert.True(view.TryGetProperty("filterMode", out _)); + Assert.AreEqual("diff", view.GetProperty("mode").GetString()); + Assert.IsTrue(view.TryGetProperty("tab", out _)); + Assert.IsTrue(view.TryGetProperty("filterMode", out _)); } // --- NuGet mode: real DotsiderDiagnosticsListener with real NuGetPackageAnalyzer --- @@ -151,7 +153,7 @@ public async Task GetSessionInfo_DiffMode_ReturnsBothAssemblyAndView() /// /// Discovery surfaces a real NuGet-mode listener and includes its packageId in the info. /// - [Fact] + [TestMethod] public async Task DiscoverDotsiderSessions_FindsNugetModeInstance() { var (pid, listener, package) = CreateRealNugetListener(); @@ -167,23 +169,23 @@ public async Task DiscoverDotsiderSessions_FindsNugetModeInstance() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var sessions = JsonSerializer.Deserialize(text); - Assert.NotNull(sessions); + Assert.IsNotNull(sessions); var nugetSession = sessions!.FirstOrDefault(s => s.GetProperty("pid").GetInt32() == pid); - Assert.NotEqual(default, nugetSession); - Assert.Equal("nuget", nugetSession.GetProperty("info").GetProperty("mode").GetString()); - Assert.Equal("RichLibrary", + Assert.AreNotEqual(default, nugetSession); + Assert.AreEqual("nuget", nugetSession.GetProperty("info").GetProperty("mode").GetString()); + Assert.AreEqual("RichLibrary", nugetSession.GetProperty("info").GetProperty("packageId").GetString()); } /// /// NuGet-mode session info reports package metadata alongside the browsing-package view state. /// - [Fact] + [TestMethod] public async Task GetSessionInfo_NugetMode_ReturnsBothAssemblyAndView() { var (pid, listener, package) = CreateRealNugetListener(); @@ -199,31 +201,31 @@ public async Task GetSessionInfo_NugetMode_ReturnsBothAssemblyAndView() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var doc = JsonDocument.Parse(text!); // Assembly info from the real listener's assemblyInfoProvider var assembly = doc.RootElement.GetProperty("assembly"); - Assert.Equal("nuget", assembly.GetProperty("mode").GetString()); - Assert.Equal("RichLibrary", assembly.GetProperty("packageId").GetString()); - Assert.Equal("2.5.1", assembly.GetProperty("packageVersion").GetString()); - Assert.True(assembly.GetProperty("dllCount").GetInt32() > 0); + Assert.AreEqual("nuget", assembly.GetProperty("mode").GetString()); + Assert.AreEqual("RichLibrary", assembly.GetProperty("packageId").GetString()); + Assert.AreEqual("2.5.1", assembly.GetProperty("packageVersion").GetString()); + Assert.IsGreaterThan(0, assembly.GetProperty("dllCount").GetInt32()); // View from the real listener's currentViewProvider var view = doc.RootElement.GetProperty("view"); - Assert.Equal("nuget", view.GetProperty("mode").GetString()); - Assert.True(view.GetProperty("isBrowsingPackage").GetBoolean()); + Assert.AreEqual("nuget", view.GetProperty("mode").GetString()); + Assert.IsTrue(view.GetProperty("isBrowsingPackage").GetBoolean()); } // --- Helpers --- - private (int pid, DotsiderDiagnosticsListener listener, AnalyzerPair analyzers) + private static (int pid, DotsiderDiagnosticsListener listener, AnalyzerPair analyzers) CreateRealDiffListener() { var pid = Interlocked.Increment(ref s_nextPid); - var left = new AssemblyAnalyzer(samples.RichLibraryDll); - var right = new AssemblyAnalyzer(samples.RichLibraryV2Dll); + var left = new AssemblyAnalyzer(Samples.RichLibraryDll); + var right = new AssemblyAnalyzer(Samples.RichLibraryV2Dll); var listener = new DotsiderDiagnosticsListener( () => null, @@ -261,11 +263,11 @@ public async Task GetSessionInfo_NugetMode_ReturnsBothAssemblyAndView() return (pid, listener, new AnalyzerPair(left, right)); } - private (int pid, DotsiderDiagnosticsListener listener, NuGetPackageAnalyzer package) + private static (int pid, DotsiderDiagnosticsListener listener, NuGetPackageAnalyzer package) CreateRealNugetListener() { var pid = Interlocked.Increment(ref s_nextPid); - var package = new NuGetPackageAnalyzer(samples.RichLibraryNupkg); + var package = new NuGetPackageAnalyzer(Samples.RichLibraryNupkg); var listener = new DotsiderDiagnosticsListener( () => null, diff --git a/tests/Dotsider.Mcp.Tests/SizeToolsTests.cs b/tests/Dotsider.Mcp.Tests/SizeToolsTests.cs index 4d80aa58..a62b7630 100644 --- a/tests/Dotsider.Mcp.Tests/SizeToolsTests.cs +++ b/tests/Dotsider.Mcp.Tests/SizeToolsTests.cs @@ -8,13 +8,15 @@ namespace Dotsider.Mcp.Tests; /// /// Creates the tests using the shared sample assembly fixture. /// -[Collection("SampleAssemblies")] -public class SizeToolsTests(SampleAssemblyFixture samples) : McpServerTestBase +[TestClass] +public class SizeToolsTests : McpServerTestBase { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// get_size_breakdown produces an error-free hierarchical size payload for a real library. /// - [Fact] + [TestMethod] public async Task GetSizeBreakdown_RichLibrary_ReturnsSizeTree() { await StartServerAsync(); @@ -22,18 +24,18 @@ public async Task GetSizeBreakdown_RichLibrary_ReturnsSizeTree() var result = await client.CallToolAsync( "get_size_breakdown", - new Dictionary { ["assemblyPath"] = samples.RichLibraryDll }, + new Dictionary { ["assemblyPath"] = Samples.RichLibraryDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.DoesNotContain("Error", text); } /// /// get_largest_methods honors maxResults and returns the top-N largest IL bodies. /// - [Fact] + [TestMethod] public async Task GetLargestMethods_RichLibrary_ReturnsSortedMethods() { await StartServerAsync(); @@ -43,22 +45,22 @@ public async Task GetLargestMethods_RichLibrary_ReturnsSortedMethods() "get_largest_methods", new Dictionary { - ["assemblyPath"] = samples.RichLibraryDll, + ["assemblyPath"] = Samples.RichLibraryDll, ["maxResults"] = 5 }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var methods = JsonSerializer.Deserialize(text); - Assert.True(methods.GetArrayLength() > 0); - Assert.True(methods.GetArrayLength() <= 5); + Assert.IsGreaterThan(0, methods.GetArrayLength()); + Assert.IsLessThanOrEqualTo(5, methods.GetArrayLength()); } /// /// Without an explicit limit, get_largest_methods caps output at the 20-method default. /// - [Fact] + [TestMethod] public async Task GetLargestMethods_DefaultCount_Returns20OrFewer() { await StartServerAsync(); @@ -66,36 +68,36 @@ public async Task GetLargestMethods_DefaultCount_Returns20OrFewer() var result = await client.CallToolAsync( "get_largest_methods", - new Dictionary { ["assemblyPath"] = samples.ComplexAppDll }, + new Dictionary { ["assemblyPath"] = Samples.ComplexAppDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var methods = JsonSerializer.Deserialize(text); - Assert.True(methods.GetArrayLength() <= 20); + Assert.IsLessThanOrEqualTo(20, methods.GetArrayLength()); } /// /// get_size_breakdown on a Native AOT binary with an mstat sidecar returns the AOT tree: /// assembly subtrees plus category nodes, not an empty root. /// - [Fact] + [TestMethod] public async Task GetSizeBreakdown_NativeAot_ReturnsAotTree() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); await StartServerAsync(); await using var client = await CreateClientAsync(); var result = await client.CallToolAsync( "get_size_breakdown", - new Dictionary { ["assemblyPath"] = samples.NativeAotConsoleExe }, + new Dictionary { ["assemblyPath"] = Samples.NativeAotConsoleExe }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var tree = JsonSerializer.Deserialize(text); - Assert.True(tree.GetProperty("size").GetInt64() > 0); + Assert.IsGreaterThan(0, tree.GetProperty("size").GetInt64()); Assert.Contains("category", text); Assert.Contains("System.Private.CoreLib", text); } @@ -103,10 +105,11 @@ public async Task GetSizeBreakdown_NativeAot_ReturnsAotTree() /// /// get_largest_methods uses Native AOT mstat method sizes when IL bodies are absent. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetLargestMethods_NativeAot_ReturnsMstatMethods() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); await StartServerAsync(); await using var client = await CreateClientAsync(); @@ -115,19 +118,19 @@ public async Task GetLargestMethods_NativeAot_ReturnsMstatMethods() "get_largest_methods", new Dictionary { - ["assemblyPath"] = samples.NativeAotConsoleExe, + ["assemblyPath"] = Samples.NativeAotConsoleExe, ["maxResults"] = 5 }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var methods = JsonSerializer.Deserialize(text); - Assert.True(methods.GetArrayLength() > 0); - Assert.All(methods.EnumerateArray(), m => + Assert.IsGreaterThan(0, methods.GetArrayLength()); + TestAssert.All(methods.EnumerateArray(), m => { - Assert.Equal("Mstat", m.GetProperty("source").GetString()); - Assert.True(m.GetProperty("size").GetInt64() > 0); + Assert.AreEqual("Mstat", m.GetProperty("source").GetString()); + Assert.IsGreaterThan(0, m.GetProperty("size").GetInt64()); }); } } diff --git a/tests/Dotsider.Mcp.Tests/StringToolsTests.cs b/tests/Dotsider.Mcp.Tests/StringToolsTests.cs index 47aa732d..7de8e266 100644 --- a/tests/Dotsider.Mcp.Tests/StringToolsTests.cs +++ b/tests/Dotsider.Mcp.Tests/StringToolsTests.cs @@ -8,13 +8,15 @@ namespace Dotsider.Mcp.Tests; /// /// Creates the tests using the shared sample assembly fixture. /// -[Collection("SampleAssemblies")] -public class StringToolsTests(SampleAssemblyFixture samples) : McpServerTestBase +[TestClass] +public class StringToolsTests : McpServerTestBase { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// extract_strings returns user, metadata, and raw string categories in a single payload. /// - [Fact] + [TestMethod] public async Task ExtractStrings_RichLibrary_ReturnsStringCategories() { await StartServerAsync(); @@ -22,21 +24,21 @@ public async Task ExtractStrings_RichLibrary_ReturnsStringCategories() var result = await client.CallToolAsync( "extract_strings", - new Dictionary { ["assemblyPath"] = samples.RichLibraryDll }, + new Dictionary { ["assemblyPath"] = Samples.RichLibraryDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.True(json.TryGetProperty("userStrings", out _)); - Assert.True(json.TryGetProperty("metadataStrings", out _)); - Assert.True(json.TryGetProperty("rawStrings", out _)); + Assert.IsTrue(json.TryGetProperty("userStrings", out _)); + Assert.IsTrue(json.TryGetProperty("metadataStrings", out _)); + Assert.IsTrue(json.TryGetProperty("rawStrings", out _)); } /// /// A query filter restricts extract_strings output to substring-matching entries. /// - [Fact] + [TestMethod] public async Task ExtractStrings_WithQuery_FiltersResults() { await StartServerAsync(); @@ -46,21 +48,21 @@ public async Task ExtractStrings_WithQuery_FiltersResults() "extract_strings", new Dictionary { - ["assemblyPath"] = samples.RichLibraryDll, + ["assemblyPath"] = Samples.RichLibraryDll, ["query"] = "Hello" }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.True(json.TryGetProperty("userStrings", out _)); + Assert.IsTrue(json.TryGetProperty("userStrings", out _)); } /// /// maxResults caps each string category to avoid flooding the MCP client. /// - [Fact] + [TestMethod] public async Task ExtractStrings_WithMaxResults_LimitsOutput() { await StartServerAsync(); @@ -70,17 +72,17 @@ public async Task ExtractStrings_WithMaxResults_LimitsOutput() "extract_strings", new Dictionary { - ["assemblyPath"] = samples.RichLibraryDll, + ["assemblyPath"] = Samples.RichLibraryDll, ["maxResults"] = 2 }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); if (json.TryGetProperty("userStrings", out var user)) { - Assert.True(user.GetArrayLength() <= 2); + Assert.IsLessThanOrEqualTo(2, user.GetArrayLength()); } } @@ -88,10 +90,11 @@ public async Task ExtractStrings_WithMaxResults_LimitsOutput() /// extract_strings on a Native AOT executable returns raw ASCII and raw UTF-16 /// strings even though the metadata heaps are absent. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ExtractStrings_NativeAot_ReturnsRawAndUtf16() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); await StartServerAsync(); @@ -101,25 +104,26 @@ public async Task ExtractStrings_NativeAot_ReturnsRawAndUtf16() "extract_strings", new Dictionary { - ["assemblyPath"] = samples.NativeAotConsoleExe, + ["assemblyPath"] = Samples.NativeAotConsoleExe, ["minLength"] = 8, ["maxResults"] = 50 }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.Empty(json.GetProperty("userStrings").EnumerateArray()); - Assert.True(json.GetProperty("rawStrings").GetArrayLength() > 0); - Assert.True(json.GetProperty("rawUtf16Strings").GetArrayLength() > 0); + Assert.IsEmpty(json.GetProperty("userStrings").EnumerateArray()); + Assert.IsGreaterThan(0, json.GetProperty("rawStrings").GetArrayLength()); + Assert.IsGreaterThan(0, json.GetProperty("rawUtf16Strings").GetArrayLength()); } /// /// extract_strings always includes the rawUtf16Strings category for managed /// assemblies too. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ExtractStrings_Managed_HasRawUtf16Field() { await StartServerAsync(); @@ -127,13 +131,13 @@ public async Task ExtractStrings_Managed_HasRawUtf16Field() var result = await client.CallToolAsync( "extract_strings", - new Dictionary { ["assemblyPath"] = samples.RichLibraryDll }, + new Dictionary { ["assemblyPath"] = Samples.RichLibraryDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.True(json.TryGetProperty("rawUtf16Strings", out _)); + Assert.IsTrue(json.TryGetProperty("rawUtf16Strings", out _)); } /// @@ -141,25 +145,25 @@ public async Task ExtractStrings_Managed_HasRawUtf16Field() /// platform — from the file-backed region on Windows and macOS, and from the rehydrated /// dehydrated data on Linux. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ExtractStrings_NativeAot_IncludesFrozenStrings() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); await StartServerAsync(); await using var client = await CreateClientAsync(); var result = await client.CallToolAsync( "extract_strings", - new Dictionary { ["assemblyPath"] = samples.NativeAotConsoleExe }, + new Dictionary { ["assemblyPath"] = Samples.NativeAotConsoleExe }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); var frozen = json.GetProperty("frozenStrings"); - Assert.Equal(JsonValueKind.Array, frozen.ValueKind); - Assert.Contains(frozen.EnumerateArray(), - e => e.GetProperty("value").GetString()!.Contains("Hello from NativeAOT!")); + Assert.AreEqual(JsonValueKind.Array, frozen.ValueKind); + Assert.Contains(e => e.GetProperty("value").GetString()!.Contains("Hello from NativeAOT!"), frozen.EnumerateArray()); } } diff --git a/tests/Dotsider.Mcp.Tests/SymbolToolsTests.cs b/tests/Dotsider.Mcp.Tests/SymbolToolsTests.cs index 6fed4ae2..eb4adf01 100644 --- a/tests/Dotsider.Mcp.Tests/SymbolToolsTests.cs +++ b/tests/Dotsider.Mcp.Tests/SymbolToolsTests.cs @@ -9,28 +9,31 @@ namespace Dotsider.Mcp.Tests; /// Tests for the native-symbol MCP tool: get_native_symbols against the real Native AOT /// fixture and the managed-assembly error path. /// -[Collection("SampleAssemblies")] -public class SymbolToolsTests(SampleAssemblyFixture samples) : McpServerTestBase +[TestClass] +public class SymbolToolsTests : McpServerTestBase { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// get_native_symbols returns the symbol list with its provenance for a Native AOT binary. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetNativeSymbols_NativeAot_ReturnsSymbolsWithProvenance() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); await StartServerAsync(); await using var client = await CreateClientAsync(); var result = await client.CallToolAsync( "get_native_symbols", - new Dictionary { ["assemblyPath"] = samples.NativeAotConsoleExe }, + new Dictionary { ["assemblyPath"] = Samples.NativeAotConsoleExe }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); - Assert.False(text.StartsWith("Error", StringComparison.Ordinal), + Assert.IsNotNull(text); + Assert.IsFalse(text.StartsWith("Error", StringComparison.Ordinal), "the tool reported an error instead of symbols"); Assert.Contains("\"source\"", text); Assert.Contains("\"status\"", text); @@ -40,7 +43,8 @@ public async Task GetNativeSymbols_NativeAot_ReturnsSymbolsWithProvenance() /// /// get_native_symbols reports an error for a managed assembly instead of an empty list. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetNativeSymbols_Managed_ReturnsError() { await StartServerAsync(); @@ -48,28 +52,29 @@ public async Task GetNativeSymbols_Managed_ReturnsError() var result = await client.CallToolAsync( "get_native_symbols", - new Dictionary { ["assemblyPath"] = samples.RichLibraryDll }, + new Dictionary { ["assemblyPath"] = Samples.RichLibraryDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains("Error", text); Assert.Contains("managed", text); } /// get_native_disassembly decodes a function by address into structured instructions. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetNativeDisassembly_NativeAot_ByAddress_ReturnsInstructions() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); ulong va; - using (var analyzer = new Dotsider.Core.Analysis.AssemblyAnalyzer(samples.NativeAotConsoleExe!)) + using (var analyzer = new Dotsider.Core.Analysis.AssemblyAnalyzer(Samples.NativeAotConsoleExe!)) { var fn = analyzer.NativeSymbols?.Symbols.FirstOrDefault(s => s.Kind == Dotsider.Core.Analysis.Models.NativeSymbolKind.Function && s.ManagedName is not null && s.FileOffset is not null && s.Size > 0); - Assert.NotNull(fn); + Assert.IsNotNull(fn); va = fn!.VirtualAddress; } @@ -78,19 +83,20 @@ public async Task GetNativeDisassembly_NativeAot_ByAddress_ReturnsInstructions() var result = await client.CallToolAsync( "get_native_disassembly", - new Dictionary { ["address"] = $"0x{va:x}", ["assemblyPath"] = samples.NativeAotConsoleExe }, + new Dictionary { ["address"] = $"0x{va:x}", ["assemblyPath"] = Samples.NativeAotConsoleExe }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); - Assert.False(text.StartsWith("Error", StringComparison.Ordinal), "the tool reported an error"); + Assert.IsNotNull(text); + Assert.IsFalse(text.StartsWith("Error", StringComparison.Ordinal), "the tool reported an error"); Assert.Contains("\"mnemonic\"", text); } /// /// get_native_symbols returns WebAssembly function symbols for a raw SDK browser-wasm module. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetNativeSymbols_Wasm_ReturnsWebAssemblySymbols() { var wasmPath = GetWasmNativePath(); @@ -104,18 +110,19 @@ public async Task GetNativeSymbols_Wasm_ReturnsWebAssemblySymbols() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.Equal("webAssembly", json.GetProperty("source").GetString()); - Assert.Equal("wasm32", json.GetProperty("architecture").GetString()); - Assert.True(json.GetProperty("symbols").GetArrayLength() > 0); + Assert.AreEqual("webAssembly", json.GetProperty("source").GetString()); + Assert.AreEqual("wasm32", json.GetProperty("architecture").GetString()); + Assert.IsGreaterThan(0, json.GetProperty("symbols").GetArrayLength()); } /// /// get_native_disassembly decodes a WebAssembly function from a real dotnet.native.wasm /// module through the same native-tool surface used for PE, ELF, Mach-O, and ReadyToRun code. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetNativeDisassembly_Wasm_ByAddress_ReturnsInstructions() { var wasmPath = GetWasmNativePath(); @@ -140,20 +147,21 @@ public async Task GetNativeDisassembly_Wasm_ByAddress_ReturnsInstructions() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); - Assert.False(text.StartsWith("Error", StringComparison.Ordinal), text); + Assert.IsNotNull(text); + Assert.IsFalse(text.StartsWith("Error", StringComparison.Ordinal), text); var json = JsonSerializer.Deserialize(text); - Assert.Equal("Wasm32", json.GetProperty("architecture").GetString()); - Assert.Contains(json.GetProperty("instructions").EnumerateArray(), static instruction => + Assert.AreEqual("Wasm32", json.GetProperty("architecture").GetString()); + Assert.Contains(static instruction => instruction.TryGetProperty("targetName", out var targetName) - && targetName.ValueKind == JsonValueKind.String); + && targetName.ValueKind == JsonValueKind.String, json.GetProperty("instructions").EnumerateArray()); } /// /// get_native_disassembly accepts WebAssembly func:N identifiers through the same /// symbolName parameter used for PE, ELF, Mach-O, ReadyToRun, and Native AOT symbols. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetNativeDisassembly_Wasm_ByFunctionIndex_ReturnsInstructions() { var wasmPath = GetWasmNativePath(); @@ -179,15 +187,16 @@ public async Task GetNativeDisassembly_Wasm_ByFunctionIndex_ReturnsInstructions( cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); - Assert.False(text.StartsWith("Error", StringComparison.Ordinal), text); + Assert.IsNotNull(text); + Assert.IsFalse(text.StartsWith("Error", StringComparison.Ordinal), text); var json = JsonSerializer.Deserialize(text); - Assert.Equal("Wasm32", json.GetProperty("architecture").GetString()); - Assert.True(json.GetProperty("instructions").GetArrayLength() > 0); + Assert.AreEqual("Wasm32", json.GetProperty("architecture").GetString()); + Assert.IsGreaterThan(0, json.GetProperty("instructions").GetArrayLength()); } /// get_native_disassembly reports an error for a managed assembly. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetNativeDisassembly_Managed_ReturnsError() { await StartServerAsync(); @@ -195,32 +204,33 @@ public async Task GetNativeDisassembly_Managed_ReturnsError() var result = await client.CallToolAsync( "get_native_disassembly", - new Dictionary { ["symbolName"] = "Foo", ["assemblyPath"] = samples.RichLibraryDll }, + new Dictionary { ["symbolName"] = "Foo", ["assemblyPath"] = Samples.RichLibraryDll }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains("Error", text); } /// /// get_assembly_info carries the native symbol provenance fields. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetAssemblyInfo_NativeAot_CarriesSymbolProvenance() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); await StartServerAsync(); await using var client = await CreateClientAsync(); var result = await client.CallToolAsync( "get_assembly_info", - new Dictionary { ["assemblyPath"] = samples.NativeAotConsoleExe }, + new Dictionary { ["assemblyPath"] = Samples.NativeAotConsoleExe }, cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains("nativeSymbolCount", text); Assert.Contains("nativeSymbolSource", text); Assert.Contains("nativeSymbolStatus", text); @@ -229,7 +239,7 @@ public async Task GetAssemblyInfo_NativeAot_CarriesSymbolProvenance() private static NativeSymbol FindWasmFunctionWithNamedCall(AssemblyAnalyzer analyzer) { var info = analyzer.NativeSymbols; - Assert.NotNull(info); + Assert.IsNotNull(info); foreach (var symbol in info.Symbols.Take(512)) { var result = NativeDisassembler.DisassembleSymbol(analyzer, symbol); @@ -243,11 +253,11 @@ private static NativeSymbol FindWasmFunctionWithNamedCall(AssemblyAnalyzer analy throw new InvalidOperationException("No Wasm function with a named direct call was found."); } - private string GetWasmNativePath() + private static string GetWasmNativePath() { - Assert.SkipWhen(samples.WasmConsoleNativeWasm is null && samples.ReadyToRunConsoleWasmNativeWasm is null, + TestSkip.When(Samples.WasmConsoleNativeWasm is null && Samples.ReadyToRunConsoleWasmNativeWasm is null, "browser-wasm publish did not run on this leg."); - return samples.WasmConsoleNativeWasm ?? samples.ReadyToRunConsoleWasmNativeWasm!; + return Samples.WasmConsoleNativeWasm ?? Samples.ReadyToRunConsoleWasmNativeWasm!; } } diff --git a/tests/Dotsider.Mcp.Tests/ToolRegistrationTests.cs b/tests/Dotsider.Mcp.Tests/ToolRegistrationTests.cs index 4d771087..c1d8eaba 100644 --- a/tests/Dotsider.Mcp.Tests/ToolRegistrationTests.cs +++ b/tests/Dotsider.Mcp.Tests/ToolRegistrationTests.cs @@ -3,12 +3,13 @@ namespace Dotsider.Mcp.Tests; /// /// Tests that guard the advertised MCP tool surface and its metadata quality. /// +[TestClass] public class ToolRegistrationTests : McpServerTestBase { /// /// Every expected tool across the suite is present in ListTools output. /// - [Fact] + [TestMethod] public async Task ListTools_ReturnsAllRegisteredTools() { await StartServerAsync(); @@ -110,7 +111,7 @@ public async Task ListTools_ReturnsAllRegisteredTools() /// /// No registered tool ships without a human-readable description, which MCP clients rely on. /// - [Fact] + [TestMethod] public async Task AllTools_HaveDescriptions() { await StartServerAsync(); @@ -120,7 +121,7 @@ public async Task AllTools_HaveDescriptions() foreach (var tool in tools) { - Assert.False(string.IsNullOrWhiteSpace(tool.Description), + Assert.IsFalse(string.IsNullOrWhiteSpace(tool.Description), $"Tool '{tool.Name}' should have a description"); } } diff --git a/tests/Dotsider.Mcp.Tests/WasmToolsTests.cs b/tests/Dotsider.Mcp.Tests/WasmToolsTests.cs index 380924f2..84308a3d 100644 --- a/tests/Dotsider.Mcp.Tests/WasmToolsTests.cs +++ b/tests/Dotsider.Mcp.Tests/WasmToolsTests.cs @@ -6,13 +6,16 @@ namespace Dotsider.Mcp.Tests; /// /// Tests for the MCP WebAssembly tools over real SDK-produced browser-wasm output. /// -[Collection("SampleAssemblies")] -public sealed class WasmToolsTests(SampleAssemblyFixture samples) : McpServerTestBase +[TestClass] +public sealed class WasmToolsTests : McpServerTestBase { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// list_wasm_sections returns raw Wasm section payload offsets and sizes from dotnet.native.wasm. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ListWasmSections_Direct_ReturnsSections() { var wasmPath = GetWasmNativePath(); @@ -26,17 +29,18 @@ public async Task ListWasmSections_Direct_ReturnsSections() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.True(json.GetProperty("sectionCount").GetInt32() > 0); - Assert.Contains(json.GetProperty("sections").EnumerateArray(), static section => - section.GetProperty("name").GetString() == "code"); + Assert.IsGreaterThan(0, json.GetProperty("sectionCount").GetInt32()); + Assert.Contains(static section => + section.GetProperty("name").GetString() == "code", json.GetProperty("sections").EnumerateArray()); } /// /// list_wasm_functions returns imported and file-backed functions in Wasm function-index order. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ListWasmFunctions_Direct_ReturnsFunctionInventory() { var wasmPath = GetWasmNativePath(); @@ -50,18 +54,19 @@ public async Task ListWasmFunctions_Direct_ReturnsFunctionInventory() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.True(json.GetProperty("functionCount").GetInt32() > 0); + Assert.IsGreaterThan(0, json.GetProperty("functionCount").GetInt32()); var functions = json.GetProperty("functions").EnumerateArray().ToArray(); - Assert.Contains(functions, static function => function.GetProperty("isImported").GetBoolean()); - Assert.Contains(functions, static function => !function.GetProperty("isImported").GetBoolean()); + Assert.Contains(static function => function.GetProperty("isImported").GetBoolean(), functions); + Assert.Contains(static function => !function.GetProperty("isImported").GetBoolean(), functions); } /// /// list_wasm_functions forwards session requests to a running dotsider instance unchanged. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ListWasmFunctions_Session_ForwardsRequest() { await using var socket = new TestDotsiderSocket(999_997, "dotnet.native.wasm"); @@ -80,17 +85,17 @@ public async Task ListWasmFunctions_Session_ForwardsRequest() cancellationToken: TestCancellationToken); var text = GetTextContent(result); - Assert.NotNull(text); + Assert.IsNotNull(text); var json = JsonSerializer.Deserialize(text); - Assert.Equal(1, json.GetProperty("functionCount").GetInt32()); - Assert.Equal("func_0", json.GetProperty("functions")[0].GetProperty("name").GetString()); + Assert.AreEqual(1, json.GetProperty("functionCount").GetInt32()); + Assert.AreEqual("func_0", json.GetProperty("functions")[0].GetProperty("name").GetString()); } - private string GetWasmNativePath() + private static string GetWasmNativePath() { - Assert.SkipWhen(samples.WasmConsoleNativeWasm is null && samples.ReadyToRunConsoleWasmNativeWasm is null, + TestSkip.When(Samples.WasmConsoleNativeWasm is null && Samples.ReadyToRunConsoleWasmNativeWasm is null, "browser-wasm publish did not run on this leg."); - return samples.WasmConsoleNativeWasm ?? samples.ReadyToRunConsoleWasmNativeWasm!; + return Samples.WasmConsoleNativeWasm ?? Samples.ReadyToRunConsoleWasmNativeWasm!; } } diff --git a/tests/Dotsider.Tests/AgentCliTests.cs b/tests/Dotsider.Tests/AgentCliTests.cs index 03f03def..e0c2bd0b 100644 --- a/tests/Dotsider.Tests/AgentCliTests.cs +++ b/tests/Dotsider.Tests/AgentCliTests.cs @@ -5,6 +5,7 @@ namespace Dotsider.Tests; /// /// CLI integration tests for the agent command. /// +[TestClass] public class AgentCliTests { private static readonly string s_projectPath = Path.Combine( @@ -27,13 +28,13 @@ private static string DetectBuildConfig() /// /// Verifies agent init stdout writes skill content. /// - [Fact] + [TestMethod] public async Task Agent_Init_Stdout_WritesSkillContent() { var (exitCode, stdout, _) = await RunDotsiderAsync( "agent", "init", "--stdout"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("name: dotsider", stdout); Assert.Contains("dotsider analyze", stdout); Assert.Contains("dotsider sessions", stdout); @@ -42,7 +43,7 @@ public async Task Agent_Init_Stdout_WritesSkillContent() /// /// Verifies agent init with path creates file. /// - [Fact] + [TestMethod] public async Task Agent_Init_WithPath_CreatesFile() { var tempDir = Path.Combine(Path.GetTempPath(), $"dotsider-test-{Guid.NewGuid():N}"); @@ -53,8 +54,8 @@ public async Task Agent_Init_WithPath_CreatesFile() var (exitCode, stdout, _) = await RunDotsiderAsync( "agent", "init", "--path", outputPath); - Assert.Equal(0, exitCode); - Assert.True(File.Exists(outputPath)); + Assert.AreEqual(0, exitCode); + Assert.IsTrue(File.Exists(outputPath)); var content = File.ReadAllText(outputPath); Assert.Contains("name: dotsider", content); } @@ -68,7 +69,7 @@ public async Task Agent_Init_WithPath_CreatesFile() /// /// Verifies agent init no force errors if exists. /// - [Fact] + [TestMethod] public async Task Agent_Init_NoForce_ErrorsIfExists() { var tempDir = Path.Combine(Path.GetTempPath(), $"dotsider-test-{Guid.NewGuid():N}"); @@ -82,9 +83,9 @@ public async Task Agent_Init_NoForce_ErrorsIfExists() var (exitCode, _, stderr) = await RunDotsiderAsync( "agent", "init", "--path", outputPath); - Assert.NotEqual(0, exitCode); + Assert.AreNotEqual(0, exitCode); Assert.Contains("already exists", stderr); - Assert.Equal("existing content", File.ReadAllText(outputPath)); + Assert.AreEqual("existing content", File.ReadAllText(outputPath)); } finally { @@ -96,7 +97,7 @@ public async Task Agent_Init_NoForce_ErrorsIfExists() /// /// Verifies agent init force overwrites existing. /// - [Fact] + [TestMethod] public async Task Agent_Init_Force_OverwritesExisting() { var tempDir = Path.Combine(Path.GetTempPath(), $"dotsider-test-{Guid.NewGuid():N}"); @@ -110,7 +111,7 @@ public async Task Agent_Init_Force_OverwritesExisting() var (exitCode, stdout, _) = await RunDotsiderAsync( "agent", "init", "--path", outputPath, "--force"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); var content = File.ReadAllText(outputPath); Assert.Contains("name: dotsider", content); Assert.DoesNotContain("existing content", content); @@ -125,7 +126,7 @@ public async Task Agent_Init_Force_OverwritesExisting() /// /// Verifies agent init with ai creates correct path. /// - [Fact] + [TestMethod] public async Task Agent_Init_WithAi_CreatesCorrectPath() { var tempDir = Path.Combine(Path.GetTempPath(), $"dotsider-test-{Guid.NewGuid():N}"); @@ -138,10 +139,10 @@ public async Task Agent_Init_WithAi_CreatesCorrectPath() var (exitCode, stdout, _) = await RunDotsiderInDirAsync( tempDir, "agent", "init", "--ai", "claude"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); var expectedPath = Path.Combine(tempDir, ".claude", "skills", "dotsider", "SKILL.md"); - Assert.True(File.Exists(expectedPath), $"Expected file at {expectedPath}"); + Assert.IsTrue(File.Exists(expectedPath), $"Expected file at {expectedPath}"); } finally { @@ -153,13 +154,13 @@ public async Task Agent_Init_WithAi_CreatesCorrectPath() /// /// Verifies agent init no args shows usage error. /// - [Fact] + [TestMethod] public async Task Agent_Init_NoArgs_ShowsUsageError() { var (exitCode, _, stderr) = await RunDotsiderAsync( "agent", "init"); - Assert.NotEqual(0, exitCode); + Assert.AreNotEqual(0, exitCode); Assert.Contains("--ai", stderr); Assert.Contains("--path", stderr); Assert.Contains("--stdout", stderr); @@ -168,13 +169,13 @@ public async Task Agent_Init_NoArgs_ShowsUsageError() /// /// Verifies agent help shows subcommands. /// - [Fact] + [TestMethod] public async Task Agent_Help_ShowsSubcommands() { var (exitCode, stdout, _) = await RunDotsiderAsync( "agent", "--help"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("mcp", stdout); Assert.Contains("init", stdout); } @@ -192,6 +193,7 @@ public async Task Agent_Help_ShowsSubcommands() RedirectStandardOutput = true, RedirectStandardError = true, }; + TestProcessEnvironment.RemoveCodeCoverageVariables(psi); var process = Process.Start(psi)!; var stdout = await process.StandardOutput.ReadToEndAsync(); @@ -213,6 +215,7 @@ public async Task Agent_Help_ShowsSubcommands() RedirectStandardError = true, WorkingDirectory = workingDirectory, }; + TestProcessEnvironment.RemoveCodeCoverageVariables(psi); var process = Process.Start(psi)!; var stdout = await process.StandardOutput.ReadToEndAsync(); diff --git a/tests/Dotsider.Tests/ApphostDetectorTests.cs b/tests/Dotsider.Tests/ApphostDetectorTests.cs index 7b990791..bfe41979 100644 --- a/tests/Dotsider.Tests/ApphostDetectorTests.cs +++ b/tests/Dotsider.Tests/ApphostDetectorTests.cs @@ -6,75 +6,83 @@ namespace Dotsider.Tests; /// /// Tests for Apphost Detector. /// -[Collection("SampleAssemblies")] -public class ApphostDetectorTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class ApphostDetectorTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private readonly List _tempFiles = []; /// /// Verifies find companion dll apphost returns companion dll path. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindCompanionDll_Apphost_ReturnsCompanionDllPath() { - var result = ApphostDetector.FindCompanionDll(samples.HelloWorldExe); + var result = ApphostDetector.FindCompanionDll(Samples.HelloWorldExe); - Assert.NotNull(result); + Assert.IsNotNull(result); Assert.EndsWith(".dll", result!, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(result)); + Assert.IsTrue(File.Exists(result)); } /// /// Verifies find companion dll managed dll returns null. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindCompanionDll_ManagedDll_ReturnsNull() { - var result = ApphostDetector.FindCompanionDll(samples.HelloWorldDll); + var result = ApphostDetector.FindCompanionDll(Samples.HelloWorldDll); - Assert.Null(result); + Assert.IsNull(result); } /// /// Verifies find companion dll native aot exe returns null. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindCompanionDll_NativeAotExe_ReturnsNull() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var result = ApphostDetector.FindCompanionDll(samples.NativeAotConsoleExe!); + var result = ApphostDetector.FindCompanionDll(Samples.NativeAotConsoleExe!); - Assert.Null(result); + Assert.IsNull(result); } /// /// Verifies find companion dll non exe extension returns null. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindCompanionDll_NonExeExtension_ReturnsNull() { - var result = ApphostDetector.FindCompanionDll(samples.RichLibraryDll); + var result = ApphostDetector.FindCompanionDll(Samples.RichLibraryDll); - Assert.Null(result); + Assert.IsNull(result); } /// /// Verifies find companion dll non existent file returns null. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindCompanionDll_NonExistentFile_ReturnsNull() { var result = ApphostDetector.FindCompanionDll( Path.Combine(Path.GetTempPath(), "nonexistent-assembly.exe")); - Assert.Null(result); + Assert.IsNull(result); } /// /// Verifies find companion dll native exe with dll name but no hostfxr returns null. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindCompanionDll_NativeExeWithDllNameButNoHostfxr_ReturnsNull() { // Simulate a native launcher that embeds the DLL name for its own reasons @@ -93,26 +101,27 @@ public void FindCompanionDll_NativeExeWithDllNameButNoHostfxr_ReturnsNull() _tempFiles.Add(fakeExePath); // Copy a real managed .dll so the companion check would pass - File.Copy(samples.HelloWorldDll, fakeDllPath); + File.Copy(Samples.HelloWorldDll, fakeDllPath); _tempFiles.Add(fakeDllPath); _tempFiles.Add(dir); var result = ApphostDetector.FindCompanionDll(fakeExePath); - Assert.Null(result); + Assert.IsNull(result); } /// /// Verifies find companion dll dotted name apphost returns companion dll path. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindCompanionDll_DottedNameApphost_ReturnsCompanionDllPath() { - var result = ApphostDetector.FindCompanionDll(samples.DottedNameAppExe); + var result = ApphostDetector.FindCompanionDll(Samples.DottedNameAppExe); - Assert.NotNull(result); + Assert.IsNotNull(result); Assert.EndsWith("Dotted.Name.App.dll", result!, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(result)); + Assert.IsTrue(File.Exists(result)); } /// @@ -120,6 +129,7 @@ public void FindCompanionDll_DottedNameApphost_ReturnsCompanionDllPath() /// public void Dispose() { + GC.SuppressFinalize(this); foreach (var path in _tempFiles) { try @@ -129,6 +139,5 @@ public void Dispose() } catch { /* best effort */ } } - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/Arm64DecoderTests.cs b/tests/Dotsider.Tests/Arm64DecoderTests.cs index 5573db8d..63b02cdb 100644 --- a/tests/Dotsider.Tests/Arm64DecoderTests.cs +++ b/tests/Dotsider.Tests/Arm64DecoderTests.cs @@ -9,6 +9,7 @@ namespace Dotsider.Tests; /// (mov/cmp/cmn/tst/neg/mul/lsl/ubfx/cset) — cross-checked against Capstone. Every A64 instruction /// is four bytes, so the fixed length is asserted throughout. /// +[TestClass] public class Arm64DecoderTests { private static NativeInstruction One(uint word) @@ -24,81 +25,86 @@ private static NativeInstruction OneAt(uint word, ulong address) } /// Decodes representative A64 base instructions to their mnemonics and operands. - [Theory(Timeout = 30_000)] - [InlineData("ret", "", 0xD65F03C0u)] - [InlineData("nop", "", 0xD503201Fu)] - [InlineData("add", "x0, x1, x2", 0x8B020020u)] - [InlineData("add", "x0, x1, #0x4", 0x91001020u)] - [InlineData("mov", "x0, #0x1", 0xD2800020u)] - [InlineData("mov", "x0, x1", 0xAA0103E0u)] - [InlineData("cmp", "x0, #0x0", 0xF100001Fu)] - [InlineData("cmp", "x0, x1", 0xEB01001Fu)] - [InlineData("mul", "x0, x1, x2", 0x9B027C20u)] - [InlineData("lsl", "x0, x1, #0x4", 0xD37CEC20u)] - [InlineData("ubfx", "w0, w1, #0x1c, #0x1", 0x531C7020u)] - [InlineData("udiv", "w0, w1, w2", 0x1AC20820u)] - [InlineData("csel", "x0, x1, x2, eq", 0x9A820020u)] - [InlineData("cset", "w0, eq", 0x1A9F17E0u)] - [InlineData("tst", "x0, x1", 0xEA01001Fu)] - [InlineData("neg", "x0, x1", 0xCB0103E0u)] - [InlineData("and", "x0, x1, #0xff", 0x92401C20u)] - [InlineData("udf", "#0x0", 0x00000000u)] - [InlineData("udf", "#0x1", 0x00000001u)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow("ret", "", 0xD65F03C0u)] + [DataRow("nop", "", 0xD503201Fu)] + [DataRow("add", "x0, x1, x2", 0x8B020020u)] + [DataRow("add", "x0, x1, #0x4", 0x91001020u)] + [DataRow("mov", "x0, #0x1", 0xD2800020u)] + [DataRow("mov", "x0, x1", 0xAA0103E0u)] + [DataRow("cmp", "x0, #0x0", 0xF100001Fu)] + [DataRow("cmp", "x0, x1", 0xEB01001Fu)] + [DataRow("mul", "x0, x1, x2", 0x9B027C20u)] + [DataRow("lsl", "x0, x1, #0x4", 0xD37CEC20u)] + [DataRow("ubfx", "w0, w1, #0x1c, #0x1", 0x531C7020u)] + [DataRow("udiv", "w0, w1, w2", 0x1AC20820u)] + [DataRow("csel", "x0, x1, x2, eq", 0x9A820020u)] + [DataRow("cset", "w0, eq", 0x1A9F17E0u)] + [DataRow("tst", "x0, x1", 0xEA01001Fu)] + [DataRow("neg", "x0, x1", 0xCB0103E0u)] + [DataRow("and", "x0, x1, #0xff", 0x92401C20u)] + [DataRow("udf", "#0x0", 0x00000000u)] + [DataRow("udf", "#0x1", 0x00000001u)] public void Decode_Base_MnemonicAndOperands(string mnemonic, string operands, uint word) { var insn = One(word); - Assert.False(insn.IsFallback); - Assert.Equal(mnemonic, insn.Mnemonic); - Assert.Equal(operands, insn.OperandText); - Assert.Equal(4, insn.Length); + Assert.IsFalse(insn.IsFallback); + Assert.AreEqual(mnemonic, insn.Mnemonic); + Assert.AreEqual(operands, insn.OperandText); + Assert.AreEqual(4, insn.Length); } /// Verifies direct branches compute the absolute target and the flow kind. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Decode_Branches_ResolveTargetAndFlow() { var bl = One(0x94000010u); // bl +0x40 - Assert.Equal(0x1040UL, bl.TargetAddress); - Assert.Equal(NativeFlowKind.Call, bl.Flow); + Assert.AreEqual(0x1040UL, bl.TargetAddress); + Assert.AreEqual(NativeFlowKind.Call, bl.Flow); var beq = One(0x54000040u); // b.eq +8 - Assert.Equal("b.eq", beq.Mnemonic); - Assert.Equal(0x1008UL, beq.TargetAddress); - Assert.Equal(NativeFlowKind.ConditionalBranch, beq.Flow); + Assert.AreEqual("b.eq", beq.Mnemonic); + Assert.AreEqual(0x1008UL, beq.TargetAddress); + Assert.AreEqual(NativeFlowKind.ConditionalBranch, beq.Flow); var cbz = One(0xB4000040u); // cbz x0, +8 - Assert.Equal(NativeFlowKind.ConditionalBranch, cbz.Flow); + Assert.AreEqual(NativeFlowKind.ConditionalBranch, cbz.Flow); var ret = One(0xD65F03C0u); - Assert.Equal(NativeFlowKind.Return, ret.Flow); + Assert.AreEqual(NativeFlowKind.Return, ret.Flow); } /// Verifies adrp computes the page-aligned target off the aligned instruction address. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Decode_Adrp_ComputesPageTarget() { var insn = One(0x90000000u); // adrp x0, page(0) - Assert.Equal("adrp", insn.Mnemonic); - Assert.Equal(0x1000UL, insn.TargetAddress); + Assert.AreEqual("adrp", insn.Mnemonic); + Assert.AreEqual(0x1000UL, insn.TargetAddress); } /// Verifies ADR/ADRP concatenate immhi:immlo in architectural order. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Decode_AdrAndAdrp_UsesImmhiImmloOrder() { var adr = One(0x10000440u); // adr x0, +0x88 - Assert.Equal("adr", adr.Mnemonic); - Assert.Equal(0x1088UL, adr.TargetAddress); + Assert.AreEqual("adr", adr.Mnemonic); + Assert.AreEqual(0x1088UL, adr.TargetAddress); // Real osx-arm64 R2R import-slot pattern: llvm-objdump decodes this as // "adrp x11, 0x180032000" at 0x180010e28. var adrp = OneAt(0xD000010Bu, 0x180010E28UL); - Assert.Equal("adrp", adrp.Mnemonic); - Assert.Equal(0x180032000UL, adrp.TargetAddress); + Assert.AreEqual("adrp", adrp.Mnemonic); + Assert.AreEqual(0x180032000UL, adrp.TargetAddress); } /// Verifies an arm64 import-slot call sequence is named structurally. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Decode_Arm64ImportSlotCall_ResolvesTargetName() { byte[] code = @@ -125,20 +131,21 @@ static bool Resolve(ulong va, out NativeSymbolRef symbol) code, 0x180010E28UL, NativeArchitecture.Arm64, Resolve); var branch = instructions[^1]; - Assert.Equal("blr", branch.Mnemonic); - Assert.Equal(NativeTargetKind.Import, branch.TargetKind); - Assert.Equal("Console.WriteLine", branch.TargetName); + Assert.AreEqual("blr", branch.Mnemonic); + Assert.AreEqual(NativeTargetKind.Import, branch.TargetKind); + Assert.AreEqual("Console.WriteLine", branch.TargetName); } /// Verifies an unallocated word decodes as a 4-byte .word that never desyncs. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Decode_Unallocated_EmitsWord() { // bits[28:25] = 0b0001 is an unallocated top-level class (no decode group), so it desyncs // into a .word. 0x00000000 is deliberately NOT used here — it is udf #0, a defined encoding. var insn = One(0x02000000u); - Assert.True(insn.IsFallback); - Assert.Equal(".word", insn.Mnemonic); - Assert.Equal(4, insn.Length); + Assert.IsTrue(insn.IsFallback); + Assert.AreEqual(".word", insn.Mnemonic); + Assert.AreEqual(4, insn.Length); } } diff --git a/tests/Dotsider.Tests/Arm64LoadStoreTests.cs b/tests/Dotsider.Tests/Arm64LoadStoreTests.cs index 439f58d6..d375e22f 100644 --- a/tests/Dotsider.Tests/Arm64LoadStoreTests.cs +++ b/tests/Dotsider.Tests/Arm64LoadStoreTests.cs @@ -9,6 +9,7 @@ namespace Dotsider.Tests; /// PC-relative literals, exclusive and acquire/release accesses, and LSE atomics — cross-checked /// against Capstone. /// +[TestClass] public class Arm64LoadStoreTests { private static NativeInstruction One(uint word) @@ -18,39 +19,41 @@ private static NativeInstruction One(uint word) } /// Decodes representative A64 load/store forms to their mnemonics and addressing modes. - [Theory(Timeout = 30_000)] - [InlineData("ldr", "x0, [x1]", 0xF9400020u)] - [InlineData("str", "x0, [x1]", 0xF9000020u)] - [InlineData("ldrb", "w0, [x1]", 0x39400020u)] - [InlineData("ldr", "w0, [x1]", 0xB9400020u)] - [InlineData("ldr", "x0, [x30], #0x10", 0xF84107C0u)] - [InlineData("str", "x0, [sp, #0x8]!", 0xF8008FE0u)] - [InlineData("stp", "x29, x30, [sp, #-0x10]!", 0xA9BF7BFDu)] - [InlineData("ldp", "x29, x30, [sp], #0x10", 0xA8C17BFDu)] - [InlineData("ldr", "x0, [x1, x0]", 0xF8606820u)] - [InlineData("ldr", "d0, [x1]", 0xFD400020u)] - [InlineData("ldr", "q0, [x1]", 0x3DC00020u)] - [InlineData("ldr", "s0, [x1]", 0xBD400020u)] - [InlineData("ldxr", "x0, [x1]", 0xC85F7C20u)] - [InlineData("stlr", "x0, [x1]", 0xC89FFC20u)] - [InlineData("ldar", "x0, [x1]", 0xC8DFFC20u)] - [InlineData("ldaxr", "w0, [x1]", 0x885FFC20u)] - [InlineData("ldadd", "w0, w1, [x2]", 0xB8200041u)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow("ldr", "x0, [x1]", 0xF9400020u)] + [DataRow("str", "x0, [x1]", 0xF9000020u)] + [DataRow("ldrb", "w0, [x1]", 0x39400020u)] + [DataRow("ldr", "w0, [x1]", 0xB9400020u)] + [DataRow("ldr", "x0, [x30], #0x10", 0xF84107C0u)] + [DataRow("str", "x0, [sp, #0x8]!", 0xF8008FE0u)] + [DataRow("stp", "x29, x30, [sp, #-0x10]!", 0xA9BF7BFDu)] + [DataRow("ldp", "x29, x30, [sp], #0x10", 0xA8C17BFDu)] + [DataRow("ldr", "x0, [x1, x0]", 0xF8606820u)] + [DataRow("ldr", "d0, [x1]", 0xFD400020u)] + [DataRow("ldr", "q0, [x1]", 0x3DC00020u)] + [DataRow("ldr", "s0, [x1]", 0xBD400020u)] + [DataRow("ldxr", "x0, [x1]", 0xC85F7C20u)] + [DataRow("stlr", "x0, [x1]", 0xC89FFC20u)] + [DataRow("ldar", "x0, [x1]", 0xC8DFFC20u)] + [DataRow("ldaxr", "w0, [x1]", 0x885FFC20u)] + [DataRow("ldadd", "w0, w1, [x2]", 0xB8200041u)] public void Decode_LoadStore_MnemonicAndOperands(string mnemonic, string operands, uint word) { var insn = One(word); - Assert.False(insn.IsFallback); - Assert.Equal(mnemonic, insn.Mnemonic); - Assert.Equal(operands, insn.OperandText); - Assert.Equal(4, insn.Length); + Assert.IsFalse(insn.IsFallback); + Assert.AreEqual(mnemonic, insn.Mnemonic); + Assert.AreEqual(operands, insn.OperandText); + Assert.AreEqual(4, insn.Length); } /// Verifies a PC-relative literal load resolves its absolute target. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Decode_LiteralLoad_ResolvesTarget() { var insn = One(0x58000040u); // ldr x0, +8 - Assert.Equal("ldr", insn.Mnemonic); - Assert.Equal(0x1008UL, insn.TargetAddress); + Assert.AreEqual("ldr", insn.Mnemonic); + Assert.AreEqual(0x1008UL, insn.TargetAddress); } } diff --git a/tests/Dotsider.Tests/Arm64SimdTests.cs b/tests/Dotsider.Tests/Arm64SimdTests.cs index 076c2b01..33cee36b 100644 --- a/tests/Dotsider.Tests/Arm64SimdTests.cs +++ b/tests/Dotsider.Tests/Arm64SimdTests.cs @@ -8,6 +8,7 @@ namespace Dotsider.Tests; /// vector three-same and misc classes with their Vd.T arrangements, dup, dot-product, the AES /// crypto rounds, and CRC32 — cross-checked against Capstone. /// +[TestClass] public class Arm64SimdTests { private static NativeInstruction One(uint word) @@ -17,32 +18,33 @@ private static NativeInstruction One(uint word) } /// Decodes representative scalar-FP, SIMD, crypto, and CRC instructions. - [Theory(Timeout = 30_000)] - [InlineData("fadd", "s0, s1, s2", 0x1E222820u)] - [InlineData("fmul", "d0, d1, d2", 0x1E620820u)] - [InlineData("fcmp", "s0, s1", 0x1E212000u)] - [InlineData("fmov", "s0, s1", 0x1E204020u)] - [InlineData("scvtf", "s0, w0", 0x1E220000u)] - [InlineData("fcvtzs", "w0, s0", 0x1E380000u)] - [InlineData("add", "v0.4s, v1.4s, v2.4s", 0x4EA28420u)] - [InlineData("fmul", "v0.4s, v1.4s, v2.4s", 0x6E22DC20u)] - [InlineData("fadd", "v0.4s, v1.4s, v2.4s", 0x4E22D420u)] - [InlineData("and", "v0.16b, v1.16b, v2.16b", 0x4E221C20u)] - [InlineData("neg", "v0.4s, v1.4s", 0x6EA0B820u)] - [InlineData("dup", "v0.4s, w1", 0x4E040C20u)] - [InlineData("mov", "v16.s[0], w0", 0x4E041C10u)] - [InlineData("smov", "x0, v16.s[0]", 0x4E042E00u)] - [InlineData("umov", "w0, v16.b[0]", 0x0E013E00u)] - [InlineData("aese", "v0.16b, v1.16b", 0x4E284820u)] - [InlineData("crc32b", "w0, w1, w2", 0x1AC24020u)] - [InlineData("crc32x", "w0, w1, x2", 0x9AC24C20u)] - [InlineData("sdot", "v0.4s, v1.16b, v2.16b", 0x4E829420u)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow("fadd", "s0, s1, s2", 0x1E222820u)] + [DataRow("fmul", "d0, d1, d2", 0x1E620820u)] + [DataRow("fcmp", "s0, s1", 0x1E212000u)] + [DataRow("fmov", "s0, s1", 0x1E204020u)] + [DataRow("scvtf", "s0, w0", 0x1E220000u)] + [DataRow("fcvtzs", "w0, s0", 0x1E380000u)] + [DataRow("add", "v0.4s, v1.4s, v2.4s", 0x4EA28420u)] + [DataRow("fmul", "v0.4s, v1.4s, v2.4s", 0x6E22DC20u)] + [DataRow("fadd", "v0.4s, v1.4s, v2.4s", 0x4E22D420u)] + [DataRow("and", "v0.16b, v1.16b, v2.16b", 0x4E221C20u)] + [DataRow("neg", "v0.4s, v1.4s", 0x6EA0B820u)] + [DataRow("dup", "v0.4s, w1", 0x4E040C20u)] + [DataRow("mov", "v16.s[0], w0", 0x4E041C10u)] + [DataRow("smov", "x0, v16.s[0]", 0x4E042E00u)] + [DataRow("umov", "w0, v16.b[0]", 0x0E013E00u)] + [DataRow("aese", "v0.16b, v1.16b", 0x4E284820u)] + [DataRow("crc32b", "w0, w1, w2", 0x1AC24020u)] + [DataRow("crc32x", "w0, w1, x2", 0x9AC24C20u)] + [DataRow("sdot", "v0.4s, v1.16b, v2.16b", 0x4E829420u)] public void Decode_SimdFp_MnemonicAndOperands(string mnemonic, string operands, uint word) { var insn = One(word); - Assert.False(insn.IsFallback); - Assert.Equal(mnemonic, insn.Mnemonic); - Assert.Equal(operands, insn.OperandText); - Assert.Equal(4, insn.Length); + Assert.IsFalse(insn.IsFallback); + Assert.AreEqual(mnemonic, insn.Mnemonic); + Assert.AreEqual(operands, insn.OperandText); + Assert.AreEqual(4, insn.Length); } } diff --git a/tests/Dotsider.Tests/Arm64SveTests.cs b/tests/Dotsider.Tests/Arm64SveTests.cs index e138ddf3..8366e749 100644 --- a/tests/Dotsider.Tests/Arm64SveTests.cs +++ b/tests/Dotsider.Tests/Arm64SveTests.cs @@ -8,6 +8,7 @@ namespace Dotsider.Tests; /// registers, predicate generation (ptrue/whilelt), contiguous predicated loads/stores, the /// predicate-writing compares, movprfx, and the mod-immediate move — cross-checked against Capstone. /// +[TestClass] public class Arm64SveTests { private static NativeInstruction One(uint word) @@ -17,35 +18,37 @@ private static NativeInstruction One(uint word) } /// Decodes representative SVE instructions with Z/predicate registers and element suffixes. - [Theory(Timeout = 30_000)] - [InlineData("add", "z0.s, z0.s, z0.s", 0x04A00000u)] - [InlineData("sub", "z0.s, z0.s, z0.s", 0x04A00400u)] - [InlineData("add", "z0.b, p0/m, z0.b, z0.b", 0x04000000u)] - [InlineData("mul", "z0.b, p0/m, z0.b, z0.b", 0x04100000u)] - [InlineData("fadd", "z0.s, p0/m, z0.s, z0.s", 0x65808000u)] - [InlineData("frintn", "z0.s, p0/m, z0.s", 0x6580A000u)] - [InlineData("ptrue", "p0.s, pow2", 0x2598E000u)] - [InlineData("whilelt", "p0.b, x0, x0", 0x25201400u)] - [InlineData("ld1w", "{z0.s}, p0/z, [x0, x0, lsl #2]", 0xA5404000u)] - [InlineData("st1w", "{z0.s}, p0, [x0, x0, lsl #2]", 0xE5404000u)] - [InlineData("ld1d", "{z0.d}, p0/z, [x0, x0, lsl #3]", 0xA5E04000u)] - [InlineData("cmphs", "p0.d, p0/z, z0.d, z0.d", 0x24C00000u)] - [InlineData("cmpge", "p0.b, p4/z, z0.b, #1", 0x25011000u)] - [InlineData("movprfx", "z0, z0", 0x0420BC00u)] - [InlineData("mov", "z0.b, #1", 0x2538C020u)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow("add", "z0.s, z0.s, z0.s", 0x04A00000u)] + [DataRow("sub", "z0.s, z0.s, z0.s", 0x04A00400u)] + [DataRow("add", "z0.b, p0/m, z0.b, z0.b", 0x04000000u)] + [DataRow("mul", "z0.b, p0/m, z0.b, z0.b", 0x04100000u)] + [DataRow("fadd", "z0.s, p0/m, z0.s, z0.s", 0x65808000u)] + [DataRow("frintn", "z0.s, p0/m, z0.s", 0x6580A000u)] + [DataRow("ptrue", "p0.s, pow2", 0x2598E000u)] + [DataRow("whilelt", "p0.b, x0, x0", 0x25201400u)] + [DataRow("ld1w", "{z0.s}, p0/z, [x0, x0, lsl #2]", 0xA5404000u)] + [DataRow("st1w", "{z0.s}, p0, [x0, x0, lsl #2]", 0xE5404000u)] + [DataRow("ld1d", "{z0.d}, p0/z, [x0, x0, lsl #3]", 0xA5E04000u)] + [DataRow("cmphs", "p0.d, p0/z, z0.d, z0.d", 0x24C00000u)] + [DataRow("cmpge", "p0.b, p4/z, z0.b, #1", 0x25011000u)] + [DataRow("movprfx", "z0, z0", 0x0420BC00u)] + [DataRow("mov", "z0.b, #1", 0x2538C020u)] public void Decode_Sve_MnemonicAndOperands(string mnemonic, string operands, uint word) { var insn = One(word); - Assert.False(insn.IsFallback); - Assert.Equal(mnemonic, insn.Mnemonic); - Assert.Equal(operands, insn.OperandText); - Assert.Equal(4, insn.Length); + Assert.IsFalse(insn.IsFallback); + Assert.AreEqual(mnemonic, insn.Mnemonic); + Assert.AreEqual(operands, insn.OperandText); + Assert.AreEqual(4, insn.Length); } /// Verifies SVE instructions classify as Vector. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Decode_Sve_ClassifiesVector() { - Assert.Equal(NativeInstructionCategory.Vector, One(0x04A00000u).Category); + Assert.AreEqual(NativeInstructionCategory.Vector, One(0x04A00000u).Category); } } diff --git a/tests/Dotsider.Tests/AssemblyAnalyzerTests.cs b/tests/Dotsider.Tests/AssemblyAnalyzerTests.cs index 3a11d09d..8aa7c725 100644 --- a/tests/Dotsider.Tests/AssemblyAnalyzerTests.cs +++ b/tests/Dotsider.Tests/AssemblyAnalyzerTests.cs @@ -7,103 +7,114 @@ namespace Dotsider.Tests; /// /// Tests for Assembly Analyzer. /// -[Collection("SampleAssemblies")] -public class AssemblyAnalyzerTests(SampleAssemblyFixture samples) +[TestClass] +public class AssemblyAnalyzerTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + // --- HelloWorld (Exe, minimal) --- /// /// Verifies hello world has correct name. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HelloWorld_HasCorrectName() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); - Assert.Equal("HelloWorld.dll", a.FileName); - Assert.Equal("HelloWorld", a.AssemblyName); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); + Assert.AreEqual("HelloWorld.dll", a.FileName); + Assert.AreEqual("HelloWorld", a.AssemblyName); } /// /// Verifies hello world has metadata. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HelloWorld_HasMetadata() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); - Assert.True(a.HasMetadata); - Assert.NotNull(a.GetMetadataReader()); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); + Assert.IsTrue(a.HasMetadata); + Assert.IsNotNull(a.GetMetadataReader()); } /// /// Verifies hello world has target framework. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HelloWorld_HasTargetFramework() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); - Assert.NotNull(a.TargetFramework); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); + Assert.IsNotNull(a.TargetFramework); Assert.Contains("10.0", a.TargetFramework); } /// /// Verifies hello world has clr header with entry point. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HelloWorld_HasClrHeaderWithEntryPoint() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); - Assert.NotNull(a.ClrHeader); - Assert.True(a.ClrHeader!.EntryPointToken > 0); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); + Assert.IsNotNull(a.ClrHeader); + Assert.IsGreaterThan(0, a.ClrHeader!.EntryPointToken); } /// /// Verifies hello world has pe headers. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HelloWorld_HasPeHeaders() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); - Assert.NotNull(a.PeHeaders); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); + Assert.IsNotNull(a.PeHeaders); } /// /// Verifies hello world has text section. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HelloWorld_HasTextSection() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); - Assert.Contains(a.Sections, s => s.Name == ".text"); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); + Assert.Contains(s => s.Name == ".text", a.Sections); } /// /// Verifies hello world raw bytes match file size. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HelloWorld_RawBytesMatchFileSize() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); - Assert.Equal(a.FileSize, a.RawBytes.Length); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); + Assert.AreEqual(a.FileSize, (long)a.RawBytes.Length); } /// /// Verifies hello world has type defs. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HelloWorld_HasTypeDefs() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); - Assert.NotEmpty(a.TypeDefs); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); + Assert.IsNotEmpty(a.TypeDefs); } /// /// Verifies hello world has method defs. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HelloWorld_HasMethodDefs() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); - Assert.NotEmpty(a.MethodDefs); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); + Assert.IsNotEmpty(a.MethodDefs); } // --- RichLibrary (Library, NuGet deps) --- @@ -111,88 +122,96 @@ public void HelloWorld_HasMethodDefs() /// /// Verifies rich library has correct version. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_HasCorrectVersion() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); - Assert.NotNull(a.AssemblyVersion); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); + Assert.IsNotNull(a.AssemblyVersion); Assert.Contains("2.5.1", a.AssemblyVersion); } /// /// Verifies rich library has newton soft ref. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_HasNewtonSoftRef() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); - Assert.Contains(a.AssemblyRefs, r => r.Name == "Newtonsoft.Json"); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); + Assert.Contains(r => r.Name == "Newtonsoft.Json", a.AssemblyRefs); } /// /// Verifies rich library has system text json ref. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_HasSystemTextJsonRef() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); - Assert.Contains(a.AssemblyRefs, r => r.Name == "System.Text.Json"); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); + Assert.Contains(r => r.Name == "System.Text.Json", a.AssemblyRefs); } /// /// Verifies rich library has service types. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_HasServiceTypes() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); - Assert.Contains(a.TypeDefs, t => t.FullName == "RichLibrary.Services.UserService"); - Assert.Contains(a.TypeDefs, t => t.FullName == "RichLibrary.Services.ProductCatalog"); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); + Assert.Contains(t => t.FullName == "RichLibrary.Services.UserService", a.TypeDefs); + Assert.Contains(t => t.FullName == "RichLibrary.Services.ProductCatalog", a.TypeDefs); } /// /// Verifies rich library has no entry point. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_HasNoEntryPoint() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); - Assert.NotNull(a.ClrHeader); - Assert.Equal(0, a.ClrHeader!.EntryPointToken); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); + Assert.IsNotNull(a.ClrHeader); + Assert.AreEqual(0, a.ClrHeader!.EntryPointToken); } /// /// Verifies rich library has many methods. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_HasManyMethods() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); - Assert.True(a.MethodDefs.Count > 10); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); + Assert.IsGreaterThan(10, a.MethodDefs.Count); } /// /// Verifies rich library opens its matching portable PDB sidecar. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_OpensPortablePdbSidecar() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); - Assert.True(a.HasPortablePdb); - Assert.Equal(PdbProvenanceKind.Sidecar, a.PdbProvenance.Kind); - Assert.NotNull(a.PdbProvenance.Path); - Assert.NotNull(a.GetPdbReader()); - Assert.Contains(a.DebugDirectory, entry => entry.Type == DebugDirectoryEntryType.CodeView); + Assert.IsTrue(a.HasPortablePdb); + Assert.AreEqual(PdbProvenanceKind.Sidecar, a.PdbProvenance.Kind); + Assert.IsNotNull(a.PdbProvenance.Path); + Assert.IsNotNull(a.GetPdbReader()); + Assert.Contains(entry => entry.Type == DebugDirectoryEntryType.CodeView, a.DebugDirectory); } /// /// Verifies rich library source link mappings resolve method documents. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_SourceLink_ResolvesMethodDocument() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var method = FindMethod(a, "RichLibrary.Services.UserService", "Add"); var debugInfo = a.GetMethodDebugInfo(method); var document = debugInfo.SequencePoints @@ -201,9 +220,9 @@ public void RichLibrary_SourceLink_ResolvesMethodDocument() var url = a.ResolveSourceLinkUrl(document); - Assert.True(a.SourceLink.IsPresent); - Assert.NotEmpty(a.SourceLink.Mappings); - Assert.NotNull(url); + Assert.IsTrue(a.SourceLink.IsPresent); + Assert.IsNotEmpty(a.SourceLink.Mappings); + Assert.IsNotNull(url); Assert.Contains("raw.githubusercontent.com", url); Assert.EndsWith("UserService.cs", url, StringComparison.OrdinalIgnoreCase); } @@ -211,20 +230,20 @@ public void RichLibrary_SourceLink_ResolvesMethodDocument() /// /// Verifies method debug info exposes sequence points and PDB local names. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_MethodDebugInfo_IncludesSequencePointsAndLocals() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var method = FindMethod(a, "RichLibrary.Services.UserService", "Add"); var debugInfo = a.GetMethodDebugInfo(method); - Assert.Equal(PdbProvenanceKind.Sidecar, debugInfo.Pdb.Kind); - Assert.Contains(debugInfo.SequencePoints, - point => point.Document?.EndsWith("UserService.cs", StringComparison.OrdinalIgnoreCase) == true - && point.SourceLinkUrl is not null); - Assert.Contains(debugInfo.Locals, local => local.Name == "id"); - Assert.Contains(debugInfo.Locals, local => local.Name == "user"); + Assert.AreEqual(PdbProvenanceKind.Sidecar, debugInfo.Pdb.Kind); + Assert.Contains(point => point.Document?.EndsWith("UserService.cs", StringComparison.OrdinalIgnoreCase) == true + && point.SourceLinkUrl is not null, debugInfo.SequencePoints); + Assert.Contains(local => local.Name == "id", debugInfo.Locals); + Assert.Contains(local => local.Name == "user", debugInfo.Locals); } // --- ComplexApp (Exe, embedded resources) --- @@ -232,22 +251,24 @@ public void RichLibrary_MethodDebugInfo_IncludesSequencePointsAndLocals() /// /// Verifies complex app has embedded resources. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ComplexApp_HasEmbeddedResources() { - using var a = new AssemblyAnalyzer(samples.ComplexAppDll); - Assert.Contains(a.Resources, r => r.Name.Contains("config.json")); - Assert.Contains(a.Resources, r => r.Name.Contains("banner.txt")); + using var a = new AssemblyAnalyzer(Samples.ComplexAppDll); + Assert.Contains(r => r.Name.Contains("config.json"), a.Resources); + Assert.Contains(r => r.Name.Contains("banner.txt"), a.Resources); } /// /// Verifies complex app has version. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ComplexApp_HasVersion() { - using var a = new AssemblyAnalyzer(samples.ComplexAppDll); - Assert.NotNull(a.AssemblyVersion); + using var a = new AssemblyAnalyzer(Samples.ComplexAppDll); + Assert.IsNotNull(a.AssemblyVersion); Assert.Contains("1.0.0", a.AssemblyVersion); } @@ -256,23 +277,25 @@ public void ComplexApp_HasVersion() /// /// Verifies minimal api has asp net refs. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void MinimalApi_HasAspNetRefs() { - using var a = new AssemblyAnalyzer(samples.MinimalApiDll); + using var a = new AssemblyAnalyzer(Samples.MinimalApiDll); // Web SDK assemblies reference ASP.NET Core packages - Assert.True(a.AssemblyRefs.Count > 0); + Assert.IsGreaterThan(0, a.AssemblyRefs.Count); } /// /// Verifies minimal api has record types. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void MinimalApi_HasRecordTypes() { - using var a = new AssemblyAnalyzer(samples.MinimalApiDll); - Assert.Contains(a.TypeDefs, t => t.Name == "GreetingResponse"); - Assert.Contains(a.TypeDefs, t => t.Name == "EchoRequest"); + using var a = new AssemblyAnalyzer(Samples.MinimalApiDll); + Assert.Contains(t => t.Name == "GreetingResponse", a.TypeDefs); + Assert.Contains(t => t.Name == "EchoRequest", a.TypeDefs); } // --- NativeLib (unsafe, P/Invoke) --- @@ -280,22 +303,24 @@ public void MinimalApi_HasRecordTypes() /// /// Verifies native lib has p invoke methods. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeLib_HasPInvokeMethods() { - using var a = new AssemblyAnalyzer(samples.NativeLibDll); - Assert.Contains(a.TypeDefs, t => t.FullName == "NativeLib.NativeInterop"); - Assert.Contains(a.TypeDefs, t => t.FullName == "NativeLib.UnsafeOperations"); + using var a = new AssemblyAnalyzer(Samples.NativeLibDll); + Assert.Contains(t => t.FullName == "NativeLib.NativeInterop", a.TypeDefs); + Assert.Contains(t => t.FullName == "NativeLib.UnsafeOperations", a.TypeDefs); } /// /// Verifies native lib has fixed buffer struct. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeLib_HasFixedBufferStruct() { - using var a = new AssemblyAnalyzer(samples.NativeLibDll); - Assert.Contains(a.TypeDefs, t => t.Name == "FixedBuffer"); + using var a = new AssemblyAnalyzer(Samples.NativeLibDll); + Assert.Contains(t => t.Name == "FixedBuffer", a.TypeDefs); } // --- EmptyLib (minimal) --- @@ -303,45 +328,48 @@ public void NativeLib_HasFixedBufferStruct() /// /// Verifies empty lib has minimal type defs. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void EmptyLib_HasMinimalTypeDefs() { - using var a = new AssemblyAnalyzer(samples.EmptyLibDll); + using var a = new AssemblyAnalyzer(Samples.EmptyLibDll); // + internal Module class - Assert.True(a.TypeDefs.Count <= 3); - Assert.True(a.TypeDefs.Count >= 1); + Assert.IsLessThanOrEqualTo(3, a.TypeDefs.Count); + Assert.IsGreaterThanOrEqualTo(1, a.TypeDefs.Count); } /// /// Verifies empty lib has metadata. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void EmptyLib_HasMetadata() { - using var a = new AssemblyAnalyzer(samples.EmptyLibDll); - Assert.True(a.HasMetadata); - Assert.NotNull(a.ClrHeader); + using var a = new AssemblyAnalyzer(Samples.EmptyLibDll); + Assert.IsTrue(a.HasMetadata); + Assert.IsNotNull(a.ClrHeader); } /// /// Verifies embedded source can be decoded from an embedded portable PDB. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void EmbeddedSourceLib_DecodesEmbeddedSource() { - using var a = new AssemblyAnalyzer(samples.EmbeddedSourceLibDll); + using var a = new AssemblyAnalyzer(Samples.EmbeddedSourceLibDll); var method = FindMethod(a, "EmbeddedSourceLib.EmbeddedSourceFixture", "Compute"); var debugInfo = a.GetMethodDebugInfo(method); var source = a.GetEmbeddedSource(method); - Assert.True(a.HasPortablePdb); - Assert.Equal(PdbProvenanceKind.Embedded, a.PdbProvenance.Kind); - Assert.Contains(a.DebugDirectory, entry => entry.Type == DebugDirectoryEntryType.EmbeddedPortablePdb); - Assert.Contains(debugInfo.SequencePoints, point => point.HasEmbeddedSource); - Assert.NotNull(source); + Assert.IsTrue(a.HasPortablePdb); + Assert.AreEqual(PdbProvenanceKind.Embedded, a.PdbProvenance.Kind); + Assert.Contains(entry => entry.Type == DebugDirectoryEntryType.EmbeddedPortablePdb, a.DebugDirectory); + Assert.Contains(point => point.HasEmbeddedSource, debugInfo.SequencePoints); + Assert.IsNotNull(source); Assert.Contains("return doubled + 1;", source.Text); - Assert.NotEmpty(source.Bytes); + Assert.IsNotEmpty(source.Bytes); } // --- RichLibraryV2 (same AssemblyName as V1) --- @@ -349,33 +377,36 @@ public void EmbeddedSourceLib_DecodesEmbeddedSource() /// /// Verifies rich library v2 has same assembly name. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibraryV2_HasSameAssemblyName() { - using var a = new AssemblyAnalyzer(samples.RichLibraryV2Dll); - Assert.Equal("RichLibrary", a.AssemblyName); + using var a = new AssemblyAnalyzer(Samples.RichLibraryV2Dll); + Assert.AreEqual("RichLibrary", a.AssemblyName); } /// /// Verifies rich library v2 has version3. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibraryV2_HasVersion3() { - using var a = new AssemblyAnalyzer(samples.RichLibraryV2Dll); - Assert.NotNull(a.AssemblyVersion); + using var a = new AssemblyAnalyzer(Samples.RichLibraryV2Dll); + Assert.IsNotNull(a.AssemblyVersion); Assert.Contains("3.0.0", a.AssemblyVersion); } /// /// Verifies rich library v2 has new types. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibraryV2_HasNewTypes() { - using var a = new AssemblyAnalyzer(samples.RichLibraryV2Dll); - Assert.Contains(a.TypeDefs, t => t.Name == "Order"); - Assert.Contains(a.TypeDefs, t => t.Name == "OrderService"); + using var a = new AssemblyAnalyzer(Samples.RichLibraryV2Dll); + Assert.Contains(t => t.Name == "Order", a.TypeDefs); + Assert.Contains(t => t.Name == "OrderService", a.TypeDefs); } // --- Cross-assembly metadata checks --- @@ -383,46 +414,49 @@ public void RichLibraryV2_HasNewTypes() /// /// Verifies all samples have custom attributes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AllSamples_HaveCustomAttributes() { - string[] paths = [samples.HelloWorldDll, samples.RichLibraryDll, samples.ComplexAppDll, - samples.MinimalApiDll, samples.NativeLibDll]; + string[] paths = [Samples.HelloWorldDll, Samples.RichLibraryDll, Samples.ComplexAppDll, + Samples.MinimalApiDll, Samples.NativeLibDll]; foreach (var path in paths) { using var a = new AssemblyAnalyzer(path); - Assert.NotEmpty(a.CustomAttributes); + Assert.IsNotEmpty(a.CustomAttributes); } } /// /// Verifies all samples have positive file size. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AllSamples_HavePositiveFileSize() { - string[] paths = [samples.HelloWorldDll, samples.RichLibraryDll, samples.ComplexAppDll, - samples.MinimalApiDll, samples.NativeLibDll, samples.EmptyLibDll]; + string[] paths = [Samples.HelloWorldDll, Samples.RichLibraryDll, Samples.ComplexAppDll, + Samples.MinimalApiDll, Samples.NativeLibDll, Samples.EmptyLibDll]; foreach (var path in paths) { using var a = new AssemblyAnalyzer(path); - Assert.True(a.FileSize > 0); + Assert.IsGreaterThan(0, a.FileSize); } } /// /// Verifies all samples have clr header. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AllSamples_HaveClrHeader() { - string[] paths = [samples.HelloWorldDll, samples.RichLibraryDll, samples.ComplexAppDll, - samples.MinimalApiDll, samples.NativeLibDll, samples.EmptyLibDll]; + string[] paths = [Samples.HelloWorldDll, Samples.RichLibraryDll, Samples.ComplexAppDll, + Samples.MinimalApiDll, Samples.NativeLibDll, Samples.EmptyLibDll]; foreach (var path in paths) { using var a = new AssemblyAnalyzer(path); - Assert.NotNull(a.ClrHeader); - Assert.True(a.ClrHeader!.MetadataSize > 0); + Assert.IsNotNull(a.ClrHeader); + Assert.IsGreaterThan(0, a.ClrHeader!.MetadataSize); } } @@ -431,22 +465,24 @@ public void AllSamples_HaveClrHeader() /// /// Verifies get method body returns non null for method with il. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetMethodBody_ReturnsNonNull_ForMethodWithIl() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); var method = a.MethodDefs.First(m => m.Rva != 0); var body = a.GetMethodBody(method); - Assert.NotNull(body); + Assert.IsNotNull(body); } /// /// Verifies Dispose can be called multiple times without side effects. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Dispose_IsIdempotent() { - var a = new AssemblyAnalyzer(samples.HelloWorldDll); + var a = new AssemblyAnalyzer(Samples.HelloWorldDll); a.Dispose(); a.Dispose(); // should not throw } @@ -454,97 +490,105 @@ public void Dispose_IsIdempotent() /// /// Verifies invalid file path throws file not found. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void InvalidFilePath_ThrowsFileNotFound() { var path = Path.Combine(Path.GetTempPath(), "nonexistent-dotsider-test-" + Guid.NewGuid() + ".dll"); - Assert.Throws(() => new AssemblyAnalyzer(path)); + Assert.ThrowsExactly(() => new AssemblyAnalyzer(path)); } /// /// Verifies non dot net binary throws bad image format. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NonDotNetBinary_ThrowsBadImageFormat() { - Assert.ThrowsAny(() => new AssemblyAnalyzer(samples.NonDotNetBinaryPath)); + Assert.Throws(() => new AssemblyAnalyzer(Samples.NonDotNetBinaryPath)); } /// /// Verifies native apphost file constructor tolerates non pe binary. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeApphost_FileConstructor_ToleratesNonPeBinary() { - using var a = new AssemblyAnalyzer(samples.HelloWorldExe); + using var a = new AssemblyAnalyzer(Samples.HelloWorldExe); - Assert.False(a.HasMetadata); - Assert.True(a.FileSize > 0); - Assert.False(a.RawBytes.IsEmpty); + Assert.IsFalse(a.HasMetadata); + Assert.IsGreaterThan(0, a.FileSize); + Assert.IsFalse(a.RawBytes.IsEmpty); } /// /// Verifies native apphost byte array constructor tolerates non pe binary. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeApphost_ByteArrayConstructor_ToleratesNonPeBinary() { - var bytes = File.ReadAllBytes(samples.HelloWorldExe); - using var a = new AssemblyAnalyzer(bytes, samples.HelloWorldExe); + var bytes = File.ReadAllBytes(Samples.HelloWorldExe); + using var a = new AssemblyAnalyzer(bytes, Samples.HelloWorldExe); - Assert.False(a.HasMetadata); - Assert.Equal(bytes.Length, a.FileSize); - Assert.False(a.RawBytes.IsEmpty); + Assert.IsFalse(a.HasMetadata); + Assert.AreEqual(bytes.Length, a.FileSize); + Assert.IsFalse(a.RawBytes.IsEmpty); } /// /// Verifies native aot byte array constructor tolerates non pe binary. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeAot_ByteArrayConstructor_ToleratesNonPeBinary() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var bytes = File.ReadAllBytes(samples.NativeAotConsoleExe!); - using var a = new AssemblyAnalyzer(bytes, samples.NativeAotConsoleExe!); + var bytes = File.ReadAllBytes(Samples.NativeAotConsoleExe!); + using var a = new AssemblyAnalyzer(bytes, Samples.NativeAotConsoleExe!); - Assert.False(a.HasMetadata); - Assert.Equal(bytes.Length, a.FileSize); + Assert.IsFalse(a.HasMetadata); + Assert.AreEqual(bytes.Length, a.FileSize); } /// /// Verifies resolve token returns string. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ResolveToken_ReturnsString() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var method = a.MethodDefs[0]; var result = a.ResolveToken(method.Token); - Assert.NotNull(result); - Assert.NotEmpty(result); + Assert.IsNotNull(result); + Assert.IsNotEmpty(result); } /// /// Verifies architecture is not unknown. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Architecture_IsNotUnknown() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); - Assert.NotEqual("Unknown", a.Architecture); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); + Assert.AreNotEqual("Unknown", a.Architecture); } /// /// Verifies file properties are populated. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FileProperties_ArePopulated() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); - Assert.NotEqual(default, a.LastModified); - Assert.NotEqual(default, a.CreatedTime); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); + Assert.AreNotEqual(default, a.LastModified); + Assert.AreNotEqual(default, a.CreatedTime); } // --- Additional coverage tests --- @@ -552,121 +596,131 @@ public void FileProperties_ArePopulated() /// /// Verifies file path is absolute. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FilePath_IsAbsolute() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); - Assert.Equal(samples.HelloWorldDll, a.FilePath); - Assert.True(Path.IsPathRooted(a.FilePath)); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); + Assert.AreEqual(Samples.HelloWorldDll, a.FilePath); + Assert.IsTrue(Path.IsPathRooted(a.FilePath)); } /// /// Verifies rich library type refs non empty. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_TypeRefs_NonEmpty() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); - Assert.NotEmpty(a.TypeRefs); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); + Assert.IsNotEmpty(a.TypeRefs); } /// /// Verifies rich library member refs non empty. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_MemberRefs_NonEmpty() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); - Assert.NotEmpty(a.MemberRefs); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); + Assert.IsNotEmpty(a.MemberRefs); var first = a.MemberRefs[0]; - Assert.NotNull(first.Name); - Assert.NotNull(first.DeclaringType); + Assert.IsNotNull(first.Name); + Assert.IsNotNull(first.DeclaringType); } /// /// Verifies complex app resources have offsets. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ComplexApp_Resources_HaveOffsets() { - using var a = new AssemblyAnalyzer(samples.ComplexAppDll); + using var a = new AssemblyAnalyzer(Samples.ComplexAppDll); foreach (var r in a.Resources) { - Assert.NotNull(r.Name); - Assert.True(r.Size >= 0); + Assert.IsNotNull(r.Name); + Assert.IsGreaterThanOrEqualTo(0, r.Size); } } /// /// Verifies rich library culture is neutral. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_Culture_IsNeutral() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); - Assert.Equal("neutral", a.Culture); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); + Assert.AreEqual("neutral", a.Culture); } /// /// Verifies hello world is read only is false. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HelloWorld_IsReadOnly_IsFalse() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); - Assert.False(a.IsReadOnly); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); + Assert.IsFalse(a.IsReadOnly); } /// /// Verifies rich library type defs have properties. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_TypeDefs_HaveProperties() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var userService = a.TypeDefs.First(t => t.Name == "UserService"); - Assert.NotNull(userService.FullName); - Assert.NotNull(userService.Namespace); - Assert.True(userService.Token > 0); + Assert.IsNotNull(userService.FullName); + Assert.IsNotNull(userService.Namespace); + Assert.IsGreaterThan(0, userService.Token); } /// /// Verifies rich library method defs have signatures. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_MethodDefs_HaveSignatures() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var methods = a.MethodDefs.Where(m => m.Rva != 0).Take(5); foreach (var m in methods) { - Assert.NotNull(m.Name); - Assert.NotNull(m.Signature); - Assert.True(m.Token > 0); + Assert.IsNotNull(m.Name); + Assert.IsNotNull(m.Signature); + Assert.IsGreaterThan(0, m.Token); } } /// /// Verifies rich library type refs have properties. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_TypeRefs_HaveProperties() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); foreach (var tr in a.TypeRefs.Take(5)) { - Assert.NotNull(tr.Name); - Assert.NotNull(tr.Namespace); + Assert.IsNotNull(tr.Name); + Assert.IsNotNull(tr.Namespace); } } /// /// Verifies resolve token type def. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ResolveToken_TypeDef() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var typeDef = a.TypeDefs.First(t => t.Name != ""); var resolved = a.ResolveToken(typeDef.Token); Assert.Contains(typeDef.Name, resolved); @@ -675,34 +729,37 @@ public void ResolveToken_TypeDef() /// /// Verifies resolve token type ref. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ResolveToken_TypeRef() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var typeRef = a.TypeRefs[0]; var resolved = a.ResolveToken(typeRef.Token); - Assert.NotEmpty(resolved); + Assert.IsNotEmpty(resolved); } /// /// Verifies resolve token member ref. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ResolveToken_MemberRef() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var memberRef = a.MemberRefs[0]; var resolved = a.ResolveToken(memberRef.Token); - Assert.NotEmpty(resolved); + Assert.IsNotEmpty(resolved); } /// /// Verifies resolve token invalid token returns hex string. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ResolveToken_InvalidToken_ReturnsHexString() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var result = a.ResolveToken(0x7F000001); Assert.Contains("0x", result); } @@ -710,182 +767,195 @@ public void ResolveToken_InvalidToken_ReturnsHexString() /// /// Verifies rich library assembly refs have version and token. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_AssemblyRefs_HaveVersionAndToken() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var newtonsoftRef = a.AssemblyRefs.First(r => r.Name == "Newtonsoft.Json"); - Assert.NotNull(newtonsoftRef.Version); - Assert.NotNull(newtonsoftRef.PublicKeyToken); - Assert.NotNull(newtonsoftRef.Culture); + Assert.IsNotNull(newtonsoftRef.Version); + Assert.IsNotNull(newtonsoftRef.PublicKeyToken); + Assert.IsNotNull(newtonsoftRef.Culture); } /// /// Verifies rich library custom attributes have properties. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_CustomAttributes_HaveProperties() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); foreach (var attr in a.CustomAttributes.Take(3)) { - Assert.NotNull(attr.Constructor); - Assert.NotNull(attr.Parent); + Assert.IsNotNull(attr.Constructor); + Assert.IsNotNull(attr.Parent); } } /// /// Verifies hello world get method body returns non null body. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HelloWorld_GetMethodBody_ReturnsNonNullBody() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); var method = a.MethodDefs.First(m => m.Rva != 0); var body = a.GetMethodBody(method); - Assert.NotNull(body); - Assert.True(body!.GetILBytes()!.Length > 0); + Assert.IsNotNull(body); + Assert.IsGreaterThan(0, body!.GetILBytes()!.Length); } /// /// Verifies rich library sections have properties. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_Sections_HaveProperties() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var textSection = a.Sections.First(s => s.Name == ".text"); - Assert.True(textSection.RawDataSize > 0); - Assert.True(textSection.VirtualAddress > 0); + Assert.IsGreaterThan(0, textSection.RawDataSize); + Assert.IsGreaterThan(0, textSection.VirtualAddress); } /// /// Verifies rich library pe headers have valid fields. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_PeHeaders_HaveValidFields() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); - Assert.NotNull(a.PeHeaders); - Assert.NotEqual((Machine)0, a.PeHeaders!.Machine); - Assert.NotEqual((Characteristics)0, a.PeHeaders.Characteristics); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); + Assert.IsNotNull(a.PeHeaders); + Assert.AreNotEqual((Machine)0, a.PeHeaders!.Machine); + Assert.AreNotEqual((Characteristics)0, a.PeHeaders.Characteristics); } /// /// Verifies access after dispose assembly refs throws instead of crashing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AccessAfterDispose_AssemblyRefs_ThrowsInsteadOfCrashing() { - var a = new AssemblyAnalyzer(samples.RichLibraryDll); - Assert.True(a.HasMetadata); + var a = new AssemblyAnalyzer(Samples.RichLibraryDll); + Assert.IsTrue(a.HasMetadata); a.Dispose(); // After Dispose, accessing AssemblyRefs must throw ObjectDisposedException // rather than crashing the process with AccessViolationException from // reading freed metadata memory. - Assert.Throws(() => _ = a.AssemblyRefs); + Assert.ThrowsExactly(() => _ = a.AssemblyRefs); } /// /// Verifies access after dispose type defs throws instead of crashing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AccessAfterDispose_TypeDefs_ThrowsInsteadOfCrashing() { - var a = new AssemblyAnalyzer(samples.RichLibraryDll); + var a = new AssemblyAnalyzer(Samples.RichLibraryDll); a.Dispose(); - Assert.Throws(() => _ = a.TypeDefs); + Assert.ThrowsExactly(() => _ = a.TypeDefs); } /// /// Verifies access after dispose method defs throws instead of crashing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AccessAfterDispose_MethodDefs_ThrowsInsteadOfCrashing() { - var a = new AssemblyAnalyzer(samples.RichLibraryDll); + var a = new AssemblyAnalyzer(Samples.RichLibraryDll); a.Dispose(); - Assert.Throws(() => _ = a.MethodDefs); + Assert.ThrowsExactly(() => _ = a.MethodDefs); } /// /// Verifies access after dispose field defs throws instead of crashing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AccessAfterDispose_FieldDefs_ThrowsInsteadOfCrashing() { - var a = new AssemblyAnalyzer(samples.RichLibraryDll); + var a = new AssemblyAnalyzer(Samples.RichLibraryDll); a.Dispose(); - Assert.Throws(() => _ = a.FieldDefs); + Assert.ThrowsExactly(() => _ = a.FieldDefs); } /// /// Verifies access after dispose get method body throws instead of crashing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AccessAfterDispose_GetMethodBody_ThrowsInsteadOfCrashing() { - var a = new AssemblyAnalyzer(samples.RichLibraryDll); + var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var method = a.MethodDefs.First(m => m.Rva > 0); a.Dispose(); - Assert.Throws(() => a.GetMethodBody(method)); + Assert.ThrowsExactly(() => a.GetMethodBody(method)); } /// /// Verifies access after dispose resolve token throws instead of crashing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AccessAfterDispose_ResolveToken_ThrowsInsteadOfCrashing() { - var a = new AssemblyAnalyzer(samples.RichLibraryDll); + var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var token = a.MethodDefs.First(m => m.Rva > 0).Token; a.Dispose(); - Assert.Throws(() => a.ResolveToken(token)); + Assert.ThrowsExactly(() => a.ResolveToken(token)); } /// /// Verifies the analyzer finds and parses the mstat and DGML sidecars published next to /// the Native AOT sample, preferring the codegen graph. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Sidecars_NativeAotExe_ProbeAndParse() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - using var a = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var a = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); - Assert.Equal(samples.NativeAotConsoleMstat, a.MstatPath); - Assert.NotNull(a.Mstat); - Assert.NotEmpty(a.Mstat.Methods); + Assert.AreEqual(Samples.NativeAotConsoleMstat, a.MstatPath); + Assert.IsNotNull(a.Mstat); + Assert.IsNotEmpty(a.Mstat.Methods); - if (samples.NativeAotConsoleDgml is not null) + if (Samples.NativeAotConsoleDgml is not null) { - Assert.Equal(samples.NativeAotConsoleDgml, a.DgmlPath); - Assert.NotNull(a.Dgml); - Assert.NotEmpty(a.Dgml.Nodes); + Assert.AreEqual(Samples.NativeAotConsoleDgml, a.DgmlPath); + Assert.IsNotNull(a.Dgml); + Assert.IsNotEmpty(a.Dgml.Nodes); } } /// /// Verifies an AOT binary with no sidecars beside it probes to null without throwing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Sidecars_NativeAotExeWithoutSidecars_ReturnNull() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); var dir = Directory.CreateTempSubdirectory("dotsider-nosidecar-"); try { - var exeCopy = Path.Combine(dir.FullName, Path.GetFileName(samples.NativeAotConsoleExe!)); - File.Copy(samples.NativeAotConsoleExe!, exeCopy); + var exeCopy = Path.Combine(dir.FullName, Path.GetFileName(Samples.NativeAotConsoleExe!)); + File.Copy(Samples.NativeAotConsoleExe!, exeCopy); using var a = new AssemblyAnalyzer(exeCopy); - Assert.Null(a.MstatPath); - Assert.Null(a.Mstat); - Assert.Null(a.DgmlPath); - Assert.Null(a.Dgml); + Assert.IsNull(a.MstatPath); + Assert.IsNull(a.Mstat); + Assert.IsNull(a.DgmlPath); + Assert.IsNull(a.Dgml); } finally { @@ -897,21 +967,22 @@ public void Sidecars_NativeAotExeWithoutSidecars_ReturnNull() /// Verifies a managed assembly never probes for sidecars, even when a stray mstat sits /// next to it — the binary-kind gate short-circuits first. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Sidecars_ManagedAssemblyWithStrayMstat_ReturnsNull() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); var dir = Directory.CreateTempSubdirectory("dotsider-stray-"); try { var dllCopy = Path.Combine(dir.FullName, "RichLibrary.dll"); - File.Copy(samples.RichLibraryDll, dllCopy); - File.Copy(samples.NativeAotConsoleMstat!, Path.Combine(dir.FullName, "RichLibrary.mstat")); + File.Copy(Samples.RichLibraryDll, dllCopy); + File.Copy(Samples.NativeAotConsoleMstat!, Path.Combine(dir.FullName, "RichLibrary.mstat")); using var a = new AssemblyAnalyzer(dllCopy); - Assert.Null(a.MstatPath); - Assert.Null(a.Mstat); + Assert.IsNull(a.MstatPath); + Assert.IsNull(a.Mstat); } finally { @@ -922,23 +993,24 @@ public void Sidecars_ManagedAssemblyWithStrayMstat_ReturnsNull() /// /// Verifies a corrupt sidecar reads as null rather than throwing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Sidecars_CorruptMstat_ReturnsNull() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); var dir = Directory.CreateTempSubdirectory("dotsider-corrupt-"); try { - var name = Path.GetFileName(samples.NativeAotConsoleExe!); + var name = Path.GetFileName(Samples.NativeAotConsoleExe!); var exeCopy = Path.Combine(dir.FullName, name); - File.Copy(samples.NativeAotConsoleExe!, exeCopy); + File.Copy(Samples.NativeAotConsoleExe!, exeCopy); var stem = name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ? name[..^4] : name; File.WriteAllBytes(Path.Combine(dir.FullName, stem + ".mstat"), [0xDE, 0xAD]); using var a = new AssemblyAnalyzer(exeCopy); - Assert.NotNull(a.MstatPath); - Assert.Null(a.Mstat); + Assert.IsNotNull(a.MstatPath); + Assert.IsNull(a.Mstat); } finally { @@ -950,39 +1022,41 @@ public void Sidecars_CorruptMstat_ReturnsNull() /// Verifies a Native AOT binary with its matching PDB beside it is marked NativePdb, carrying /// the PDB path, rather than the UnsupportedWindowsPdb it used to report. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Provenance_NativeAotExeWithMatchingPdb_IsNativePdb() { - Assert.SkipWhen( - samples.NativeAotConsoleSymbols is null - || !samples.NativeAotConsoleSymbols.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase), + TestSkip.When( + Samples.NativeAotConsoleSymbols is null + || !Samples.NativeAotConsoleSymbols.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase), "native PDB not present on this platform"); - using var a = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var a = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); - Assert.Equal(PdbProvenanceKind.NativePdb, a.PdbProvenance.Kind); - Assert.Equal(samples.NativeAotConsoleSymbols, a.PdbProvenance.Path); + Assert.AreEqual(PdbProvenanceKind.NativePdb, a.PdbProvenance.Kind); + Assert.AreEqual(Samples.NativeAotConsoleSymbols, a.PdbProvenance.Path); } /// /// Verifies a Native AOT binary copied away from its PDB falls back to UnsupportedWindowsPdb. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Provenance_NativeAotExeWithoutPdb_IsUnsupportedWindowsPdb() { - Assert.SkipWhen( - samples.NativeAotConsoleSymbols is null - || !samples.NativeAotConsoleSymbols.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase), + TestSkip.When( + Samples.NativeAotConsoleSymbols is null + || !Samples.NativeAotConsoleSymbols.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase), "native PDB not present on this platform"); var dir = Directory.CreateTempSubdirectory("dotsider-nopdb-"); try { - var exeCopy = Path.Combine(dir.FullName, Path.GetFileName(samples.NativeAotConsoleExe!)); - File.Copy(samples.NativeAotConsoleExe!, exeCopy); + var exeCopy = Path.Combine(dir.FullName, Path.GetFileName(Samples.NativeAotConsoleExe!)); + File.Copy(Samples.NativeAotConsoleExe!, exeCopy); using var a = new AssemblyAnalyzer(exeCopy); - Assert.Equal(PdbProvenanceKind.UnsupportedWindowsPdb, a.PdbProvenance.Kind); + Assert.AreEqual(PdbProvenanceKind.UnsupportedWindowsPdb, a.PdbProvenance.Kind); } finally { @@ -994,31 +1068,32 @@ samples.NativeAotConsoleSymbols is null /// Verifies a PDB whose GUID does not match the binary is rejected as UnsupportedWindowsPdb, /// not accepted as a native match. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Provenance_NativeAotExeWithWrongPdb_IsUnsupportedWindowsPdb() { - Assert.SkipWhen( - samples.NativeAotConsoleSymbols is null - || !samples.NativeAotConsoleSymbols.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase), + TestSkip.When( + Samples.NativeAotConsoleSymbols is null + || !Samples.NativeAotConsoleSymbols.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase), "native PDB not present on this platform"); var dir = Directory.CreateTempSubdirectory("dotsider-wrongpdb-"); try { - var name = Path.GetFileName(samples.NativeAotConsoleExe!); + var name = Path.GetFileName(Samples.NativeAotConsoleExe!); var exeCopy = Path.Combine(dir.FullName, name); - File.Copy(samples.NativeAotConsoleExe!, exeCopy); + File.Copy(Samples.NativeAotConsoleExe!, exeCopy); var stem = name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ? name[..^4] : name; // A valid PDB whose GUID no longer matches the binary: flip the info-stream GUID bytes // in a copy of the real PDB, leaving the container otherwise intact. - var pdb = File.ReadAllBytes(samples.NativeAotConsoleSymbols!); + var pdb = File.ReadAllBytes(Samples.NativeAotConsoleSymbols!); MutatePdbGuid(pdb); File.WriteAllBytes(Path.Combine(dir.FullName, stem + ".pdb"), pdb); using var a = new AssemblyAnalyzer(exeCopy); - Assert.Equal(PdbProvenanceKind.UnsupportedWindowsPdb, a.PdbProvenance.Kind); + Assert.AreEqual(PdbProvenanceKind.UnsupportedWindowsPdb, a.PdbProvenance.Kind); } finally { @@ -1060,7 +1135,7 @@ private static MethodDefInfo FindMethod(AssemblyAnalyzer analyzer, string typeNa m.DeclaringType == typeName && m.Name == methodName); - Assert.NotNull(method); + Assert.IsNotNull(method); return method; } } diff --git a/tests/Dotsider.Tests/AssemblyDifferTests.cs b/tests/Dotsider.Tests/AssemblyDifferTests.cs index 1341b76e..c7e01ddc 100644 --- a/tests/Dotsider.Tests/AssemblyDifferTests.cs +++ b/tests/Dotsider.Tests/AssemblyDifferTests.cs @@ -6,138 +6,150 @@ namespace Dotsider.Tests; /// /// Tests for Assembly Differ. /// -[Collection("SampleAssemblies")] -public class AssemblyDifferTests(SampleAssemblyFixture samples) +[TestClass] +public class AssemblyDifferTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// Verifies rich library v1vs v2 has non empty diff. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibraryV1vsV2_HasNonEmptyDiff() { - using var left = new AssemblyAnalyzer(samples.RichLibraryDll); - using var right = new AssemblyAnalyzer(samples.RichLibraryV2Dll); + using var left = new AssemblyAnalyzer(Samples.RichLibraryDll); + using var right = new AssemblyAnalyzer(Samples.RichLibraryV2Dll); var result = AssemblyDiffer.Compare(left, right); - Assert.NotEmpty(result.TypeDiffs); - Assert.NotEmpty(result.MethodDiffs); + Assert.IsNotEmpty(result.TypeDiffs); + Assert.IsNotEmpty(result.MethodDiffs); } /// /// Verifies v1vs v2 type diffs i repository removed. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void V1vsV2_TypeDiffs_IRepositoryRemoved() { - using var left = new AssemblyAnalyzer(samples.RichLibraryDll); - using var right = new AssemblyAnalyzer(samples.RichLibraryV2Dll); + using var left = new AssemblyAnalyzer(Samples.RichLibraryDll); + using var right = new AssemblyAnalyzer(Samples.RichLibraryV2Dll); var result = AssemblyDiffer.Compare(left, right); - Assert.Contains(result.TypeDiffs, d => - d.Kind == DiffKind.Removed && d.Left!.Name.Contains("IRepository")); + Assert.Contains(d => + d.Kind == DiffKind.Removed && d.Left!.Name.Contains("IRepository"), result.TypeDiffs); } /// /// Verifies v1vs v2 type diffs order added. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void V1vsV2_TypeDiffs_OrderAdded() { - using var left = new AssemblyAnalyzer(samples.RichLibraryDll); - using var right = new AssemblyAnalyzer(samples.RichLibraryV2Dll); + using var left = new AssemblyAnalyzer(Samples.RichLibraryDll); + using var right = new AssemblyAnalyzer(Samples.RichLibraryV2Dll); var result = AssemblyDiffer.Compare(left, right); - Assert.Contains(result.TypeDiffs, d => - d.Kind == DiffKind.Added && d.Right!.Name == "Order"); + Assert.Contains(d => + d.Kind == DiffKind.Added && d.Right!.Name == "Order", result.TypeDiffs); } /// /// Verifies v1vs v2 ref diffs system text json removed. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void V1vsV2_RefDiffs_SystemTextJsonRemoved() { - using var left = new AssemblyAnalyzer(samples.RichLibraryDll); - using var right = new AssemblyAnalyzer(samples.RichLibraryV2Dll); + using var left = new AssemblyAnalyzer(Samples.RichLibraryDll); + using var right = new AssemblyAnalyzer(Samples.RichLibraryV2Dll); var result = AssemblyDiffer.Compare(left, right); - Assert.Contains(result.AssemblyRefDiffs, d => - d.Kind == DiffKind.Removed && d.Left!.Name == "System.Text.Json"); + Assert.Contains(d => + d.Kind == DiffKind.Removed && d.Left!.Name == "System.Text.Json", result.AssemblyRefDiffs); } /// /// Verifies v1vs v2 summary has positive counts. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void V1vsV2_Summary_HasPositiveCounts() { - using var left = new AssemblyAnalyzer(samples.RichLibraryDll); - using var right = new AssemblyAnalyzer(samples.RichLibraryV2Dll); + using var left = new AssemblyAnalyzer(Samples.RichLibraryDll); + using var right = new AssemblyAnalyzer(Samples.RichLibraryV2Dll); var result = AssemblyDiffer.Compare(left, right); - Assert.True(result.MetadataSummary.TypesAdded > 0); - Assert.True(result.MetadataSummary.TypesRemoved > 0); - Assert.True(result.MetadataSummary.MethodsAdded > 0); + Assert.IsGreaterThan(0, result.MetadataSummary.TypesAdded); + Assert.IsGreaterThan(0, result.MetadataSummary.TypesRemoved); + Assert.IsGreaterThan(0, result.MetadataSummary.MethodsAdded); } /// /// Verifies same assembly all unchanged. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SameAssembly_AllUnchanged() { - using var left = new AssemblyAnalyzer(samples.RichLibraryDll); - using var right = new AssemblyAnalyzer(samples.RichLibraryDll); + using var left = new AssemblyAnalyzer(Samples.RichLibraryDll); + using var right = new AssemblyAnalyzer(Samples.RichLibraryDll); var result = AssemblyDiffer.Compare(left, right); - Assert.DoesNotContain(result.TypeDiffs, d => d.Kind == DiffKind.Added); - Assert.DoesNotContain(result.TypeDiffs, d => d.Kind == DiffKind.Removed); - Assert.DoesNotContain(result.MethodDiffs, d => d.Kind == DiffKind.Added); - Assert.DoesNotContain(result.MethodDiffs, d => d.Kind == DiffKind.Removed); + Assert.DoesNotContain(d => d.Kind == DiffKind.Added, result.TypeDiffs); + Assert.DoesNotContain(d => d.Kind == DiffKind.Removed, result.TypeDiffs); + Assert.DoesNotContain(d => d.Kind == DiffKind.Added, result.MethodDiffs); + Assert.DoesNotContain(d => d.Kind == DiffKind.Removed, result.MethodDiffs); } /// /// Verifies v1vs v2 method diffs signature changes detected. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void V1vsV2_MethodDiffs_SignatureChangesDetected() { - using var left = new AssemblyAnalyzer(samples.RichLibraryDll); - using var right = new AssemblyAnalyzer(samples.RichLibraryV2Dll); + using var left = new AssemblyAnalyzer(Samples.RichLibraryDll); + using var right = new AssemblyAnalyzer(Samples.RichLibraryV2Dll); var result = AssemblyDiffer.Compare(left, right); // There should be methods that changed signature - Assert.True(result.MetadataSummary.MethodsChanged > 0 || + Assert.IsTrue(result.MetadataSummary.MethodsChanged > 0 || result.MetadataSummary.MethodsAdded > 0); } /// /// Verifies v1vs v2 ref diffs newtonsoft still present. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void V1vsV2_RefDiffs_NewtonsoftStillPresent() { - using var left = new AssemblyAnalyzer(samples.RichLibraryDll); - using var right = new AssemblyAnalyzer(samples.RichLibraryV2Dll); + using var left = new AssemblyAnalyzer(Samples.RichLibraryDll); + using var right = new AssemblyAnalyzer(Samples.RichLibraryV2Dll); var result = AssemblyDiffer.Compare(left, right); // Newtonsoft.Json was dropped in V2 — should be Removed - Assert.Contains(result.AssemblyRefDiffs, d => - d.Kind == DiffKind.Removed && d.Left?.Name == "Newtonsoft.Json"); + Assert.Contains(d => + d.Kind == DiffKind.Removed && d.Left?.Name == "Newtonsoft.Json", result.AssemblyRefDiffs); } /// /// Verifies v1vs v2 size delta is non zero. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void V1vsV2_SizeDelta_IsNonZero() { - using var left = new AssemblyAnalyzer(samples.RichLibraryDll); - using var right = new AssemblyAnalyzer(samples.RichLibraryV2Dll); + using var left = new AssemblyAnalyzer(Samples.RichLibraryDll); + using var right = new AssemblyAnalyzer(Samples.RichLibraryV2Dll); var result = AssemblyDiffer.Compare(left, right); - Assert.NotEqual(0, result.MetadataSummary.SizeDelta); + Assert.AreNotEqual(0, result.MetadataSummary.SizeDelta); } /// /// Verifies v1vs v2 diff entries have correct kinds. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void V1vsV2_DiffEntries_HaveCorrectKinds() { - using var left = new AssemblyAnalyzer(samples.RichLibraryDll); - using var right = new AssemblyAnalyzer(samples.RichLibraryV2Dll); + using var left = new AssemblyAnalyzer(Samples.RichLibraryDll); + using var right = new AssemblyAnalyzer(Samples.RichLibraryV2Dll); var result = AssemblyDiffer.Compare(left, right); var kinds = result.TypeDiffs.Select(d => d.Kind).Distinct().ToHashSet(); // Should have at least Added, Removed, and Unchanged @@ -149,14 +161,15 @@ public void V1vsV2_DiffEntries_HaveCorrectKinds() /// /// Verifies v1vs v2 type diffs audit log added. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void V1vsV2_TypeDiffs_AuditLogAdded() { - using var left = new AssemblyAnalyzer(samples.RichLibraryDll); - using var right = new AssemblyAnalyzer(samples.RichLibraryV2Dll); + using var left = new AssemblyAnalyzer(Samples.RichLibraryDll); + using var right = new AssemblyAnalyzer(Samples.RichLibraryV2Dll); var result = AssemblyDiffer.Compare(left, right); - Assert.Contains(result.TypeDiffs, d => - d.Kind == DiffKind.Added && d.Right!.Name == "AuditLog"); + Assert.Contains(d => + d.Kind == DiffKind.Added && d.Right!.Name == "AuditLog", result.TypeDiffs); } /// @@ -164,19 +177,20 @@ public void V1vsV2_TypeDiffs_AuditLogAdded() /// Product.PrintMembers has same signature in v1/v2 but different IL because /// the Product record shape changed (StockCount→Quantity, added Sku). /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void V1vsV2_MethodDiffs_BodyChangesDetected() { - using var left = new AssemblyAnalyzer(samples.RichLibraryDll); - using var right = new AssemblyAnalyzer(samples.RichLibraryV2Dll); + using var left = new AssemblyAnalyzer(Samples.RichLibraryDll); + using var right = new AssemblyAnalyzer(Samples.RichLibraryV2Dll); var result = AssemblyDiffer.Compare(left, right); var printMembers = result.MethodDiffs.FirstOrDefault(d => (d.Left ?? d.Right)!.Name == "PrintMembers" && (d.Left ?? d.Right)!.DeclaringType.Contains("Product")); - Assert.NotNull(printMembers); - Assert.Equal(DiffKind.Changed, printMembers.Kind); + Assert.IsNotNull(printMembers); + Assert.AreEqual(DiffKind.Changed, printMembers.Kind); Assert.Contains("body", printMembers.ChangeDescription!); } @@ -184,38 +198,40 @@ public void V1vsV2_MethodDiffs_BodyChangesDetected() /// Verifies source-identical methods survive token renumbering without false positives. /// CountActive is source-identical in v1/v2 but Product changed shape, causing token churn. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void V1vsV2_MethodDiffs_SourceIdenticalMethodStaysUnchanged() { - using var left = new AssemblyAnalyzer(samples.RichLibraryDll); - using var right = new AssemblyAnalyzer(samples.RichLibraryV2Dll); + using var left = new AssemblyAnalyzer(Samples.RichLibraryDll); + using var right = new AssemblyAnalyzer(Samples.RichLibraryV2Dll); var result = AssemblyDiffer.Compare(left, right); var countActive = result.MethodDiffs.FirstOrDefault(d => (d.Left ?? d.Right)!.Name == "CountActive" && (d.Left ?? d.Right)!.DeclaringType.Contains("ProductCatalog")); - Assert.NotNull(countActive); - Assert.Equal(DiffKind.Unchanged, countActive.Kind); + Assert.IsNotNull(countActive); + Assert.AreEqual(DiffKind.Unchanged, countActive.Kind); } /// /// Verifies exception region changes are detected. /// TryFindById catches Exception (v1) vs InvalidOperationException (v2). /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void V1vsV2_MethodDiffs_ExceptionRegionChangeDetected() { - using var left = new AssemblyAnalyzer(samples.RichLibraryDll); - using var right = new AssemblyAnalyzer(samples.RichLibraryV2Dll); + using var left = new AssemblyAnalyzer(Samples.RichLibraryDll); + using var right = new AssemblyAnalyzer(Samples.RichLibraryV2Dll); var result = AssemblyDiffer.Compare(left, right); var tryFindById = result.MethodDiffs.FirstOrDefault(d => (d.Left ?? d.Right)!.Name == "TryFindById" && (d.Left ?? d.Right)!.DeclaringType.Contains("UserService")); - Assert.NotNull(tryFindById); - Assert.Equal(DiffKind.Changed, tryFindById.Kind); + Assert.IsNotNull(tryFindById); + Assert.AreEqual(DiffKind.Changed, tryFindById.Kind); Assert.Contains("body", tryFindById.ChangeDescription!); } @@ -223,19 +239,20 @@ public void V1vsV2_MethodDiffs_ExceptionRegionChangeDetected() /// Verifies local signature changes are detected. /// SummarizeUsers has 1 local (v1) vs 2+ locals (v2). /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void V1vsV2_MethodDiffs_LocalSignatureChangeDetected() { - using var left = new AssemblyAnalyzer(samples.RichLibraryDll); - using var right = new AssemblyAnalyzer(samples.RichLibraryV2Dll); + using var left = new AssemblyAnalyzer(Samples.RichLibraryDll); + using var right = new AssemblyAnalyzer(Samples.RichLibraryV2Dll); var result = AssemblyDiffer.Compare(left, right); var summarize = result.MethodDiffs.FirstOrDefault(d => (d.Left ?? d.Right)!.Name == "SummarizeUsers" && (d.Left ?? d.Right)!.DeclaringType.Contains("UserService")); - Assert.NotNull(summarize); - Assert.Equal(DiffKind.Changed, summarize.Kind); + Assert.IsNotNull(summarize); + Assert.AreEqual(DiffKind.Changed, summarize.Kind); Assert.Contains("body", summarize.ChangeDescription!); } @@ -243,10 +260,11 @@ public void V1vsV2_MethodDiffs_LocalSignatureChangeDetected() /// Isolated test: LocalSignaturesDiffer returns true for two methods with /// different non-empty local signatures, exercising the element-by-element path. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void LocalSignaturesDiffer_DifferentLocals_ReturnsTrue() { - using var analyzer = new AssemblyAnalyzer(samples.RichLibraryDll); + using var analyzer = new AssemblyAnalyzer(Samples.RichLibraryDll); var reader = analyzer.GetMetadataReader()!; // Add has locals (int id, User user); SummarizeUsers has local (int count) @@ -258,89 +276,94 @@ public void LocalSignaturesDiffer_DifferentLocals_ReturnsTrue() var addBody = analyzer.GetMethodBody(addMethod)!; var summarizeBody = analyzer.GetMethodBody(summarizeMethod)!; - Assert.True(AssemblyDiffer.LocalSignaturesDiffer(reader, addBody, reader, summarizeBody)); + Assert.IsTrue(AssemblyDiffer.LocalSignaturesDiffer(reader, addBody, reader, summarizeBody)); } /// /// Isolated test: LocalSignaturesDiffer returns false for the same method body. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void LocalSignaturesDiffer_SameMethod_ReturnsFalse() { - using var analyzer = new AssemblyAnalyzer(samples.RichLibraryDll); + using var analyzer = new AssemblyAnalyzer(Samples.RichLibraryDll); var reader = analyzer.GetMetadataReader()!; var addMethod = analyzer.MethodDefs.First(m => m.Name == "Add" && m.DeclaringType.Contains("UserService")); var addBody = analyzer.GetMethodBody(addMethod)!; - Assert.False(AssemblyDiffer.LocalSignaturesDiffer(reader, addBody, reader, addBody)); + Assert.IsFalse(AssemblyDiffer.LocalSignaturesDiffer(reader, addBody, reader, addBody)); } /// /// Verifies comparing an assembly against itself produces no body changes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SameAssembly_NoBodyChanges() { - using var left = new AssemblyAnalyzer(samples.RichLibraryDll); - using var right = new AssemblyAnalyzer(samples.RichLibraryDll); + using var left = new AssemblyAnalyzer(Samples.RichLibraryDll); + using var right = new AssemblyAnalyzer(Samples.RichLibraryDll); var result = AssemblyDiffer.Compare(left, right); - Assert.DoesNotContain(result.MethodDiffs, d => - d.ChangeDescription?.Contains("body") == true); + Assert.DoesNotContain(d => + d.ChangeDescription?.Contains("body") == true, result.MethodDiffs); } /// /// Verifies abstract methods (Rva == 0) are not incorrectly flagged as body-changed. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SameAssembly_AbstractMethods_NoBodyChange() { - using var left = new AssemblyAnalyzer(samples.RichLibraryDll); - using var right = new AssemblyAnalyzer(samples.RichLibraryDll); + using var left = new AssemblyAnalyzer(Samples.RichLibraryDll); + using var right = new AssemblyAnalyzer(Samples.RichLibraryDll); var result = AssemblyDiffer.Compare(left, right); var abstractMethods = result.MethodDiffs.Where(d => (d.Left ?? d.Right)!.Rva == 0); - Assert.All(abstractMethods, m => Assert.Equal(DiffKind.Unchanged, m.Kind)); + TestAssert.All(abstractMethods, m => Assert.AreEqual(DiffKind.Unchanged, m.Kind)); } /// /// Verifies that body differences increase the MethodsChanged count in the summary. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void V1vsV2_Summary_MethodsChangedIncludesBodyChanges() { - using var left = new AssemblyAnalyzer(samples.RichLibraryDll); - using var right = new AssemblyAnalyzer(samples.RichLibraryV2Dll); + using var left = new AssemblyAnalyzer(Samples.RichLibraryDll); + using var right = new AssemblyAnalyzer(Samples.RichLibraryV2Dll); var result = AssemblyDiffer.Compare(left, right); var bodyChangedCount = result.MethodDiffs.Count(d => d.ChangeDescription?.Contains("body") == true); - Assert.True(bodyChangedCount > 0, "Should detect at least one body change"); - Assert.True(result.MetadataSummary.MethodsChanged >= bodyChangedCount); + Assert.IsGreaterThan(0, bodyChangedCount, "Should detect at least one body change"); + Assert.IsGreaterThanOrEqualTo(bodyChangedCount, result.MetadataSummary.MethodsChanged); } /// /// Verifies that calli instructions with different calling conventions are detected. /// InvokeCallback uses managed (v1) vs unmanaged[Cdecl] (v2) — different StandaloneSig tokens. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void V1vsV2_MethodDiffs_CalliCallingConventionChangeDetected() { - using var left = new AssemblyAnalyzer(samples.RichLibraryDll); - using var right = new AssemblyAnalyzer(samples.RichLibraryV2Dll); + using var left = new AssemblyAnalyzer(Samples.RichLibraryDll); + using var right = new AssemblyAnalyzer(Samples.RichLibraryV2Dll); var result = AssemblyDiffer.Compare(left, right); var invokeCallback = result.MethodDiffs.FirstOrDefault(d => (d.Left ?? d.Right)!.Name == "InvokeCallback" && (d.Left ?? d.Right)!.DeclaringType.Contains("FunctionPointerHelpers")); - Assert.NotNull(invokeCallback); - Assert.Equal(DiffKind.Changed, invokeCallback.Kind); + Assert.IsNotNull(invokeCallback); + Assert.AreEqual(DiffKind.Changed, invokeCallback.Kind); Assert.Contains("body", invokeCallback.ChangeDescription!); } @@ -348,19 +371,20 @@ public void V1vsV2_MethodDiffs_CalliCallingConventionChangeDetected() /// Verifies that local variables with different function-pointer types are detected. /// HasCallback has a managed function-pointer local (v1) vs unmanaged[Cdecl] (v2). /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void V1vsV2_MethodDiffs_FunctionPointerLocalChangeDetected() { - using var left = new AssemblyAnalyzer(samples.RichLibraryDll); - using var right = new AssemblyAnalyzer(samples.RichLibraryV2Dll); + using var left = new AssemblyAnalyzer(Samples.RichLibraryDll); + using var right = new AssemblyAnalyzer(Samples.RichLibraryV2Dll); var result = AssemblyDiffer.Compare(left, right); var hasCallback = result.MethodDiffs.FirstOrDefault(d => (d.Left ?? d.Right)!.Name == "HasCallback" && (d.Left ?? d.Right)!.DeclaringType.Contains("FunctionPointerHelpers")); - Assert.NotNull(hasCallback); - Assert.Equal(DiffKind.Changed, hasCallback.Kind); + Assert.IsNotNull(hasCallback); + Assert.AreEqual(DiffKind.Changed, hasCallback.Kind); Assert.Contains("body", hasCallback.ChangeDescription!); } } diff --git a/tests/Dotsider.Tests/AssemblyLoaderTests.cs b/tests/Dotsider.Tests/AssemblyLoaderTests.cs index 9cdccb32..3d0193fd 100644 --- a/tests/Dotsider.Tests/AssemblyLoaderTests.cs +++ b/tests/Dotsider.Tests/AssemblyLoaderTests.cs @@ -7,88 +7,96 @@ namespace Dotsider.Tests; /// Tests for covering managed DLLs, apphosts, /// single-file bundles, and NativeAOT executables. /// -[Collection("SampleAssemblies")] -public sealed class AssemblyLoaderTests(SampleAssemblyFixture samples) +[TestClass] +public sealed class AssemblyLoaderTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// Verifies that a managed DLL returns a Direct result with metadata. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Open_ManagedDll_ReturnsDirect() { - var result = AssemblyLoader.Open(samples.RichLibraryDll); - Assert.IsType(result); + var result = AssemblyLoader.Open(Samples.RichLibraryDll); + Assert.IsExactInstanceOfType(result); var direct = (AssemblyOpenResult.Direct)result; - Assert.True(direct.Analyzer.HasMetadata); + Assert.IsTrue(direct.Analyzer.HasMetadata); direct.Analyzer.Dispose(); } /// Verifies that an apphost exe returns ApphostWithCompanion with a valid companion path. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Open_ApphostExe_ReturnsApphostWithCompanion() { - var result = AssemblyLoader.Open(samples.HelloWorldExe); - Assert.IsType(result); + var result = AssemblyLoader.Open(Samples.HelloWorldExe); + Assert.IsExactInstanceOfType(result); var apphost = (AssemblyOpenResult.ApphostWithCompanion)result; - Assert.False(apphost.HostAnalyzer.HasMetadata); + Assert.IsFalse(apphost.HostAnalyzer.HasMetadata); Assert.EndsWith(".dll", apphost.CompanionDllPath, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(apphost.CompanionDllPath)); + Assert.IsTrue(File.Exists(apphost.CompanionDllPath)); apphost.HostAnalyzer.Dispose(); } /// Verifies that a single-file bundle returns a BundleEntry with valid metadata. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Open_SingleFileBundle_ReturnsBundleEntry() { - Assert.NotNull(samples.SelfContainedConsoleExe); - var result = AssemblyLoader.Open(samples.SelfContainedConsoleExe!); - Assert.IsType(result); + Assert.IsNotNull(Samples.SelfContainedConsoleExe); + var result = AssemblyLoader.Open(Samples.SelfContainedConsoleExe!); + Assert.IsExactInstanceOfType(result); var bundle = (AssemblyOpenResult.BundleEntry)result; - Assert.True(bundle.EntryAnalyzer.HasMetadata); - Assert.Equal("SelfContainedConsole", bundle.EntryAnalyzer.AssemblyName); - Assert.Equal(samples.SelfContainedConsoleExe, bundle.BundlePath); - Assert.Equal(samples.SelfContainedConsoleExe, bundle.EntryAnalyzer.SourceBundlePath); + Assert.IsTrue(bundle.EntryAnalyzer.HasMetadata); + Assert.AreEqual("SelfContainedConsole", bundle.EntryAnalyzer.AssemblyName); + Assert.AreEqual(Samples.SelfContainedConsoleExe, bundle.BundlePath); + Assert.AreEqual(Samples.SelfContainedConsoleExe, bundle.EntryAnalyzer.SourceBundlePath); // FilePath is the on-disk bundle path; DisplayName is the entry assembly name - Assert.Equal(samples.SelfContainedConsoleExe, bundle.EntryAnalyzer.FilePath); - Assert.Equal("SelfContainedConsole.dll", bundle.EntryAnalyzer.DisplayName); + Assert.AreEqual(Samples.SelfContainedConsoleExe, bundle.EntryAnalyzer.FilePath); + Assert.AreEqual("SelfContainedConsole.dll", bundle.EntryAnalyzer.DisplayName); bundle.EntryAnalyzer.Dispose(); } /// Verifies that bundle-backed analyzers expose correct capabilities. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Open_SingleFileBundle_HasCorrectCapabilities() { - Assert.NotNull(samples.SelfContainedConsoleExe); - var result = AssemblyLoader.Open(samples.SelfContainedConsoleExe!); + Assert.IsNotNull(Samples.SelfContainedConsoleExe); + var result = AssemblyLoader.Open(Samples.SelfContainedConsoleExe!); var bundle = (AssemblyOpenResult.BundleEntry)result; - Assert.True(bundle.EntryAnalyzer.IsBundleBacked); - Assert.False(bundle.EntryAnalyzer.CanSaveInPlace); - Assert.Equal(samples.SelfContainedConsoleExe, bundle.EntryAnalyzer.LaunchPath); + Assert.IsTrue(bundle.EntryAnalyzer.IsBundleBacked); + Assert.IsFalse(bundle.EntryAnalyzer.CanSaveInPlace); + Assert.AreEqual(Samples.SelfContainedConsoleExe, bundle.EntryAnalyzer.LaunchPath); bundle.EntryAnalyzer.Dispose(); } /// Verifies that file-backed analyzers expose correct capabilities. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Open_ManagedDll_HasCorrectCapabilities() { - var result = AssemblyLoader.Open(samples.RichLibraryDll); + var result = AssemblyLoader.Open(Samples.RichLibraryDll); var direct = (AssemblyOpenResult.Direct)result; - Assert.False(direct.Analyzer.IsBundleBacked); - Assert.True(direct.Analyzer.CanSaveInPlace); - Assert.Equal(samples.RichLibraryDll, direct.Analyzer.LaunchPath); - Assert.Equal(direct.Analyzer.FileName, direct.Analyzer.DisplayName); + Assert.IsFalse(direct.Analyzer.IsBundleBacked); + Assert.IsTrue(direct.Analyzer.CanSaveInPlace); + Assert.AreEqual(Samples.RichLibraryDll, direct.Analyzer.LaunchPath); + Assert.AreEqual(direct.Analyzer.FileName, direct.Analyzer.DisplayName); direct.Analyzer.Dispose(); } /// Verifies that a NativeAOT exe returns a NativeAot result without metadata. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Open_NativeAotExe_ReturnsNativeAot() { - Assert.NotNull(samples.NativeAotConsoleExe); - var result = AssemblyLoader.Open(samples.NativeAotConsoleExe!); - Assert.IsType(result); + Assert.IsNotNull(Samples.NativeAotConsoleExe); + var result = AssemblyLoader.Open(Samples.NativeAotConsoleExe!); + Assert.IsExactInstanceOfType(result); var aot = (AssemblyOpenResult.NativeAot)result; - Assert.False(aot.Analyzer.HasMetadata); - Assert.NotNull(aot.Analyzer.NativeAotInfo); - Assert.Equal(BinaryKind.NativeAot, aot.Analyzer.BinaryKind); + Assert.IsFalse(aot.Analyzer.HasMetadata); + Assert.IsNotNull(aot.Analyzer.NativeAotInfo); + Assert.AreEqual(BinaryKind.NativeAot, aot.Analyzer.BinaryKind); aot.Analyzer.Dispose(); } @@ -97,23 +105,25 @@ public void Open_NativeAotExe_ReturnsNativeAot() /// the ReadyToRun assemblies inside it contain RTR signatures — the bundle check /// runs before the Native AOT probe. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Open_SingleFileBundle_NotClassifiedAsNativeAot() { - Assert.NotNull(samples.SelfContainedConsoleExe); - var result = AssemblyLoader.Open(samples.SelfContainedConsoleExe!); - Assert.IsType(result); + Assert.IsNotNull(Samples.SelfContainedConsoleExe); + var result = AssemblyLoader.Open(Samples.SelfContainedConsoleExe!); + Assert.IsExactInstanceOfType(result); ((AssemblyOpenResult.BundleEntry)result).EntryAnalyzer.Dispose(); } /// Verifies that a managed DLL is classified as Managed. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Open_ManagedDll_HasManagedBinaryKind() { - var result = AssemblyLoader.Open(samples.RichLibraryDll); + var result = AssemblyLoader.Open(Samples.RichLibraryDll); var direct = (AssemblyOpenResult.Direct)result; - Assert.Equal(BinaryKind.Managed, direct.Analyzer.BinaryKind); - Assert.Null(direct.Analyzer.NativeAotInfo); + Assert.AreEqual(BinaryKind.Managed, direct.Analyzer.BinaryKind); + Assert.IsNull(direct.Analyzer.NativeAotInfo); direct.Analyzer.Dispose(); } } diff --git a/tests/Dotsider.Tests/AssemblyResolutionTests.cs b/tests/Dotsider.Tests/AssemblyResolutionTests.cs index 96741bad..05997086 100644 --- a/tests/Dotsider.Tests/AssemblyResolutionTests.cs +++ b/tests/Dotsider.Tests/AssemblyResolutionTests.cs @@ -11,9 +11,11 @@ namespace Dotsider.Tests; /// Tests for assembly resolution logic including app-local, shared framework, /// bundle-backed, and type-forwarder resolution paths. /// -[Collection("SampleAssemblies")] -public sealed class AssemblyResolutionTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public sealed class AssemblyResolutionTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// Clears resolution caches after each test. public void Dispose() { @@ -22,28 +24,30 @@ public void Dispose() } /// Verifies that an app-local assembly resolves as FromFile before other probes. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ResolveAssembly_AppLocal_StillPreferred() { // HelloWorld.dll sits next to HelloWorld.exe — resolving "HelloWorld" from // the exe's directory should find the .dll app-locally var resolved = AssemblyAnalyzer.ResolveAssembly( - samples.HelloWorldExe, "HelloWorld"); - Assert.NotNull(resolved); - var fromFile = Assert.IsType(resolved); + Samples.HelloWorldExe, "HelloWorld"); + Assert.IsNotNull(resolved); + var fromFile = Assert.IsExactInstanceOfType(resolved); Assert.EndsWith("HelloWorld.dll", fromFile.Path, StringComparison.OrdinalIgnoreCase); } /// Verifies that System.Runtime resolves from the shared framework. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ResolveAssembly_FromSharedFramework_ReturnsFromFile() { // System.Runtime should be found in the shared framework var resolved = AssemblyAnalyzer.ResolveAssembly( - samples.RichLibraryDll, "System.Runtime", + Samples.RichLibraryDll, "System.Runtime", ".NETCoreApp,Version=v10.0", "Microsoft.NETCore.App"); - Assert.NotNull(resolved); - Assert.IsType(resolved); + Assert.IsNotNull(resolved); + Assert.IsExactInstanceOfType(resolved); } /// @@ -52,38 +56,41 @@ public void ResolveAssembly_FromSharedFramework_ReturnsFromFile() /// succeed first; in a real single-file host the bundle probe (step 3) would win. /// Either path is correct — the key invariant is that resolution succeeds. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ResolveAssembly_WithBundleContext_FindsSystemRuntime() { - Assert.NotNull(samples.SelfContainedConsoleExe); + Assert.IsNotNull(Samples.SelfContainedConsoleExe); var resolved = AssemblyAnalyzer.ResolveAssembly( "SelfContainedConsole.dll", "System.Runtime", - sourceBundlePath: samples.SelfContainedConsoleExe); - Assert.NotNull(resolved); + sourceBundlePath: Samples.SelfContainedConsoleExe); + Assert.IsNotNull(resolved); } /// Verifies that mscorlib type forwarders resolve correctly through a bundle. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ImplementationAssemblyResolver_WithBundle_ResolvesTypeForwarders() { - Assert.NotNull(samples.SelfContainedConsoleExe); + Assert.IsNotNull(Samples.SelfContainedConsoleExe); // mscorlib type forwarders should work through bundle-backed resolution var resolved = ImplementationAssemblyResolver.Resolve( "SelfContainedConsole.dll", "mscorlib", "System.Console", ".NETCoreApp,Version=v10.0", "Microsoft.NETCore.App", - samples.SelfContainedConsoleExe); - Assert.NotNull(resolved); + Samples.SelfContainedConsoleExe); + Assert.IsNotNull(resolved); } /// Verifies that target framework and preferred pack are threaded through resolution. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ResolveAssembly_PreferredRuntimePack_ThreadedThrough() { // Verify that target framework and preferred pack reach the locator var resolved = AssemblyAnalyzer.ResolveAssembly( - samples.RichLibraryDll, "System.Runtime", + Samples.RichLibraryDll, "System.Runtime", ".NETCoreApp,Version=v10.0", "Microsoft.NETCore.App"); - Assert.NotNull(resolved); + Assert.IsNotNull(resolved); } /// @@ -92,30 +99,32 @@ public void ResolveAssembly_PreferredRuntimePack_ThreadedThrough() /// Resolving a forwarded type must follow the ExportedType to the implementation, /// not stop at the facade just because it happens to carry usable metadata. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ImplementationAssemblyResolver_PartialFacade_ForwardedType_LandsInImplementationAssembly() { var resolved = ImplementationAssemblyResolver.Resolve( - samples.HelloWorldDll, "System.Collections", + Samples.HelloWorldDll, "System.Collections", "System.Collections.Generic.List`1", ".NETCoreApp,Version=v10.0", "Microsoft.NETCore.App"); - var fromFile = Assert.IsType(resolved); - Assert.Equal("System.Private.CoreLib.dll", Path.GetFileName(fromFile.Path)); + var fromFile = Assert.IsExactInstanceOfType(resolved); + Assert.AreEqual("System.Private.CoreLib.dll", Path.GetFileName(fromFile.Path)); } /// /// Guardrail for the same partial facade: a type the facade actually owns as a /// TypeDef must stay in the facade, not be over-chased into CoreLib. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ImplementationAssemblyResolver_PartialFacade_LocallyOwnedType_StaysInFacade() { var resolved = ImplementationAssemblyResolver.Resolve( - samples.HelloWorldDll, "System.Collections", + Samples.HelloWorldDll, "System.Collections", "System.Collections.Generic.LinkedList`1", ".NETCoreApp,Version=v10.0", "Microsoft.NETCore.App"); - var fromFile = Assert.IsType(resolved); - Assert.Equal("System.Collections.dll", Path.GetFileName(fromFile.Path)); + var fromFile = Assert.IsExactInstanceOfType(resolved); + Assert.AreEqual("System.Collections.dll", Path.GetFileName(fromFile.Path)); } /// @@ -125,7 +134,8 @@ public void ImplementationAssemblyResolver_PartialFacade_LocallyOwnedType_StaysI /// Handing a callers a non-owning assembly recreates the "method not found" /// failure downstream — the whole point of the type-aware probe is to avoid it. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ImplementationAssemblyResolver_ForwarderChaseBroken_ReturnsNullNotFacade() { // Build a synthetic facade that forwards "Sample.Forwarded" to an assembly @@ -149,7 +159,7 @@ public void ImplementationAssemblyResolver_ForwarderChaseBroken_ReturnsNullNotFa var result = ImplementationAssemblyResolver.Resolve( referencingPath, "SyntheticFacade", "Sample.Forwarded"); - Assert.Null(result); + Assert.IsNull(result); } finally { @@ -212,23 +222,24 @@ private static byte[] BuildSyntheticFacade( /// Partial-facade forwarder resolution must also work through the bundle context /// path. Drives TryResolveFromBundle explicitly by passing sourceBundlePath. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ImplementationAssemblyResolver_PartialFacade_Bundle_ForwardedType_LandsInImplementationAssembly() { - Assert.NotNull(samples.SelfContainedConsoleExe); + Assert.IsNotNull(Samples.SelfContainedConsoleExe); var resolved = ImplementationAssemblyResolver.Resolve( "SelfContainedConsole.dll", "System.Collections", "System.Collections.Generic.List`1", ".NETCoreApp,Version=v10.0", "Microsoft.NETCore.App", - samples.SelfContainedConsoleExe); - Assert.NotNull(resolved); + Samples.SelfContainedConsoleExe); + Assert.IsNotNull(resolved); var name = resolved switch { ResolvedAssembly.FromFile f => Path.GetFileName(f.Path), ResolvedAssembly.FromBundle b => b.Name, _ => null }; - Assert.Equal("System.Private.CoreLib.dll", name); + Assert.AreEqual("System.Private.CoreLib.dll", name); } } diff --git a/tests/Dotsider.Tests/CliTests.cs b/tests/Dotsider.Tests/CliTests.cs index 23f32d53..f616cc47 100644 --- a/tests/Dotsider.Tests/CliTests.cs +++ b/tests/Dotsider.Tests/CliTests.cs @@ -4,21 +4,23 @@ namespace Dotsider.Tests; /// CLI integration tests that invoke the dotsider process to verify /// argument parsing, output formatting, and error handling. /// -[Collection("SampleAssemblies")] -public class CliTests(SampleAssemblyFixture fixture) +[TestClass] +public class CliTests { + private static SampleAssemblyFixture Fixture => SampleAssemblyHost.Instance; + // --- Default analyze output --- /// /// Verifies analyze default lists types methods and references. /// - [Fact] + [TestMethod] public async Task Analyze_Default_ListsTypesMethodsAndReferences() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.HelloWorldDll); + "analyze", Fixture.HelloWorldDll); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("Types (", stdout); Assert.Contains("Methods (", stdout); Assert.Contains("References (", stdout); @@ -30,13 +32,13 @@ public async Task Analyze_Default_ListsTypesMethodsAndReferences() /// /// Verifies analyze default output includes portable PDB summary lines. /// - [Fact] + [TestMethod] public async Task Analyze_Default_ShowsPortablePdbSummary() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.RichLibraryDll); + "analyze", Fixture.RichLibraryDll); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("PDB:", stdout); Assert.Contains("Sidecar(", stdout); Assert.Contains("SourceLink: present", stdout); @@ -45,29 +47,29 @@ public async Task Analyze_Default_ShowsPortablePdbSummary() /// /// Verifies analyze default JSON includes portable PDB metadata. /// - [Fact] + [TestMethod] public async Task Analyze_Default_Json_IncludesPortablePdbMetadata() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.RichLibraryDll, "--json"); + "analyze", Fixture.RichLibraryDll, "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); var json = System.Text.Json.JsonSerializer.Deserialize(stdout); - Assert.Equal("sidecar", json.GetProperty("pdbProvenance").GetProperty("kind").GetString()); - Assert.True(json.GetProperty("sourceLink").GetProperty("isPresent").GetBoolean()); - Assert.True(json.GetProperty("debugDirectory").GetArrayLength() > 0); + Assert.AreEqual("sidecar", json.GetProperty("pdbProvenance").GetProperty("kind").GetString()); + Assert.IsTrue(json.GetProperty("sourceLink").GetProperty("isPresent").GetBoolean()); + Assert.IsGreaterThan(0, json.GetProperty("debugDirectory").GetArrayLength()); } /// /// Verifies analyze IL output includes portable PDB annotations. /// - [Fact] + [TestMethod] public async Task Analyze_Il_ShowsPortablePdbAnnotations() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.RichLibraryDll, "--il", "RichLibrary.Services.UserService.Add"); + "analyze", Fixture.RichLibraryDll, "--il", "RichLibrary.Services.UserService.Add"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("// PDB: Sidecar", stdout); Assert.Contains("// Source Link: present", stdout); Assert.Contains("UserService.cs", stdout); @@ -79,14 +81,14 @@ public async Task Analyze_Il_ShowsPortablePdbAnnotations() /// /// Verifies analyze embedded source prints source text from an embedded portable PDB. /// - [Fact] + [TestMethod] public async Task Analyze_EmbeddedSource_PrintsEmbeddedSource() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.EmbeddedSourceLibDll, "--embedded-source", + "analyze", Fixture.EmbeddedSourceLibDll, "--embedded-source", "EmbeddedSourceLib.EmbeddedSourceFixture.Compute"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("internal static class EmbeddedSourceFixture", stdout); Assert.Contains("return doubled + 1;", stdout); } @@ -96,7 +98,7 @@ public async Task Analyze_EmbeddedSource_PrintsEmbeddedSource() /// /// Verifies analyze missing input does not truncate output file. /// - [Fact] + [TestMethod] public async Task Analyze_MissingInput_DoesNotTruncateOutputFile() { var outputFile = Path.GetTempFileName(); @@ -107,9 +109,9 @@ public async Task Analyze_MissingInput_DoesNotTruncateOutputFile() var (exitCode, _, stderr) = await RunDotsiderAsync( "analyze", "nonexistent-assembly.dll", "-o", outputFile); - Assert.NotEqual(0, exitCode); + Assert.AreNotEqual(0, exitCode); Assert.Contains("File not found", stderr); - Assert.Equal("original content", File.ReadAllText(outputFile)); + Assert.AreEqual("original content", File.ReadAllText(outputFile)); } finally { @@ -120,44 +122,44 @@ public async Task Analyze_MissingInput_DoesNotTruncateOutputFile() /// /// Verifies analyze same input and output rejects with error. /// - [Fact] + [TestMethod] public async Task Analyze_SameInputAndOutput_RejectsWithError() { var (exitCode, _, stderr) = await RunDotsiderAsync( - "analyze", fixture.HelloWorldDll, "-o", fixture.HelloWorldDll); + "analyze", Fixture.HelloWorldDll, "-o", Fixture.HelloWorldDll); - Assert.NotEqual(0, exitCode); + Assert.AreNotEqual(0, exitCode); Assert.Contains("Output path cannot be the same as the input file", stderr); } /// /// Verifies analyze invalid output path produces controlled error. /// - [Fact] + [TestMethod] public async Task Analyze_InvalidOutputPath_ProducesControlledError() { var (exitCode, _, stderr) = await RunDotsiderAsync( - "analyze", fixture.HelloWorldDll, "-o", "/nonexistent/dir/report.txt"); + "analyze", Fixture.HelloWorldDll, "-o", "/nonexistent/dir/report.txt"); - Assert.NotEqual(0, exitCode); + Assert.AreNotEqual(0, exitCode); Assert.Contains("Error:", stderr); } /// /// Verifies analyze valid output writes to file. /// - [Fact] + [TestMethod] public async Task Analyze_ValidOutput_WritesToFile() { var outputFile = Path.GetTempFileName(); try { var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.HelloWorldDll, "--types", "-o", outputFile); + "analyze", Fixture.HelloWorldDll, "--types", "-o", outputFile); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); // stdout should be empty when writing to file - Assert.Empty(stdout.Trim()); + Assert.IsEmpty(stdout.Trim()); // File should have the table var content = File.ReadAllText(outputFile); Assert.Contains("Namespace", content); @@ -174,7 +176,7 @@ public async Task Analyze_ValidOutput_WritesToFile() /// /// Verifies tui mode options before file routes to tui mode. /// - [Fact] + [TestMethod] public async Task TuiMode_OptionsBeforeFile_RoutesToTuiMode() { // "--tab 2 " should enter TUI mode, not fall through to subcommand parser. @@ -183,7 +185,7 @@ public async Task TuiMode_OptionsBeforeFile_RoutesToTuiMode() var (exitCode, _, stderr) = await RunDotsiderAsync( "--tab", "2", "nonexistent-assembly.dll"); - Assert.NotEqual(0, exitCode); + Assert.AreNotEqual(0, exitCode); Assert.Contains("File not found", stderr); // Should NOT contain System.CommandLine error text Assert.DoesNotContain("Required command was not provided", stderr); @@ -192,13 +194,13 @@ public async Task TuiMode_OptionsBeforeFile_RoutesToTuiMode() /// /// Verifies tui mode options after file still work. /// - [Fact] + [TestMethod] public async Task TuiMode_OptionsAfterFile_StillWork() { var (exitCode, _, stderr) = await RunDotsiderAsync( "nonexistent-assembly.dll", "--tab", "2"); - Assert.NotEqual(0, exitCode); + Assert.AreNotEqual(0, exitCode); Assert.Contains("File not found", stderr); Assert.DoesNotContain("Required command was not provided", stderr); } @@ -208,13 +210,13 @@ public async Task TuiMode_OptionsAfterFile_StillWork() /// /// Verifies tui mode escape timeout option routes to tui mode. /// - [Fact] + [TestMethod] public async Task TuiMode_EscapeTimeoutOption_RoutesToTuiMode() { var (exitCode, _, stderr) = await RunDotsiderAsync( "--escape-timeout", "200", "nonexistent-assembly.dll"); - Assert.NotEqual(0, exitCode); + Assert.AreNotEqual(0, exitCode); Assert.Contains("File not found", stderr); Assert.DoesNotContain("Required command was not provided", stderr); } @@ -222,13 +224,13 @@ public async Task TuiMode_EscapeTimeoutOption_RoutesToTuiMode() /// /// Verifies tui mode short escape timeout alias routes to tui mode. /// - [Fact] + [TestMethod] public async Task TuiMode_ShortEscapeTimeoutAlias_RoutesToTuiMode() { var (exitCode, _, stderr) = await RunDotsiderAsync( "-e", "200", "nonexistent-assembly.dll"); - Assert.NotEqual(0, exitCode); + Assert.AreNotEqual(0, exitCode); Assert.Contains("File not found", stderr); Assert.DoesNotContain("Required command was not provided", stderr); } @@ -236,13 +238,13 @@ public async Task TuiMode_ShortEscapeTimeoutAlias_RoutesToTuiMode() /// /// Verifies diff mode escape timeout option accepted. /// - [Fact] + [TestMethod] public async Task DiffMode_EscapeTimeoutOption_Accepted() { var (exitCode, _, stderr) = await RunDotsiderAsync( "diff", "--escape-timeout", "200", "nonexistent-left.dll", "nonexistent-right.dll"); - Assert.NotEqual(0, exitCode); + Assert.AreNotEqual(0, exitCode); Assert.Contains("File not found", stderr); Assert.DoesNotContain("Unrecognized", stderr); } @@ -250,13 +252,13 @@ public async Task DiffMode_EscapeTimeoutOption_Accepted() /// /// Verifies diff mode short escape timeout alias accepted. /// - [Fact] + [TestMethod] public async Task DiffMode_ShortEscapeTimeoutAlias_Accepted() { var (exitCode, _, stderr) = await RunDotsiderAsync( "diff", "-e", "200", "nonexistent-left.dll", "nonexistent-right.dll"); - Assert.NotEqual(0, exitCode); + Assert.AreNotEqual(0, exitCode); Assert.Contains("File not found", stderr); Assert.DoesNotContain("Unrecognized", stderr); } @@ -266,12 +268,12 @@ public async Task DiffMode_ShortEscapeTimeoutAlias_Accepted() /// /// Verifies no args shows help and returns zero. /// - [Fact] + [TestMethod] public async Task NoArgs_ShowsHelpAndReturnsZero() { var (exitCode, stdout, _) = await RunDotsiderAsync(); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("dotsider", stdout); Assert.Contains("Commands:", stdout); } @@ -279,12 +281,12 @@ public async Task NoArgs_ShowsHelpAndReturnsZero() /// /// Verifies json flag alone returns non zero. /// - [Fact] + [TestMethod] public async Task JsonFlagAlone_ReturnsNonZero() { var (exitCode, _, stderr) = await RunDotsiderAsync("--json"); - Assert.NotEqual(0, exitCode); + Assert.AreNotEqual(0, exitCode); Assert.Contains("Required command was not provided", stderr); } @@ -293,13 +295,13 @@ public async Task JsonFlagAlone_ReturnsNonZero() /// /// Verifies analyze apphost auto redirects to managed dll. /// - [Fact] + [TestMethod] public async Task Analyze_Apphost_AutoRedirectsToManagedDll() { var (exitCode, stdout, stderr) = await RunDotsiderAsync( - "analyze", fixture.HelloWorldExe); + "analyze", Fixture.HelloWorldExe); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("apphost", stderr, StringComparison.OrdinalIgnoreCase); Assert.Contains("HelloWorld", stdout); } @@ -307,107 +309,107 @@ public async Task Analyze_Apphost_AutoRedirectsToManagedDll() // --- Fields --- /// Verifies that --fields lists field definitions in text mode. - [Fact] + [TestMethod] public async Task Analyze_Fields_ListsFieldDefinitions() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.RichLibraryDll, "--fields"); + "analyze", Fixture.RichLibraryDll, "--fields"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("Namespace", stdout); Assert.Contains("Name", stdout); Assert.Contains("Signature", stdout); } /// Verifies that --fields with --json outputs a JSON array. - [Fact] + [TestMethod] public async Task Analyze_Fields_Json_OutputsJson() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.RichLibraryDll, "--fields", "--json"); + "analyze", Fixture.RichLibraryDll, "--fields", "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); var json = System.Text.Json.JsonSerializer.Deserialize(stdout); - Assert.Equal(System.Text.Json.JsonValueKind.Array, json.ValueKind); - Assert.True(json.GetArrayLength() > 0); + Assert.AreEqual(System.Text.Json.JsonValueKind.Array, json.ValueKind); + Assert.IsGreaterThan(0, json.GetArrayLength()); } // --- Bundle --- /// Verifies that --bundle shows the manifest for a single-file bundle. - [Fact] + [TestMethod] public async Task Analyze_Bundle_ShowsManifest() { - Assert.NotNull(fixture.SelfContainedConsoleExe); + Assert.IsNotNull(Fixture.SelfContainedConsoleExe); var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.SelfContainedConsoleExe!, "--bundle"); + "analyze", Fixture.SelfContainedConsoleExe!, "--bundle"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("Bundle version:", stdout); Assert.Contains("Entries:", stdout); } /// Verifies that --bundle with --json outputs structured manifest data. - [Fact] + [TestMethod] public async Task Analyze_Bundle_Json_OutputsJson() { - Assert.NotNull(fixture.SelfContainedConsoleExe); + Assert.IsNotNull(Fixture.SelfContainedConsoleExe); var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.SelfContainedConsoleExe!, "--bundle", "--json"); + "analyze", Fixture.SelfContainedConsoleExe!, "--bundle", "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); var json = System.Text.Json.JsonSerializer.Deserialize(stdout); - Assert.True(json.GetProperty("fileCount").GetInt32() > 0); + Assert.IsGreaterThan(0, json.GetProperty("fileCount").GetInt32()); } /// Verifies that --bundle on a non-bundle file returns an error. - [Fact] + [TestMethod] public async Task Analyze_Bundle_NonBundle_ReturnsError() { var (exitCode, _, stderr) = await RunDotsiderAsync( - "analyze", fixture.RichLibraryDll, "--bundle"); + "analyze", Fixture.RichLibraryDll, "--bundle"); - Assert.NotEqual(0, exitCode); + Assert.AreNotEqual(0, exitCode); Assert.Contains("not a single-file bundle", stderr); } /// Verifies that --bundle -o rejects writing to the same input file. - [Fact] + [TestMethod] public async Task Analyze_Bundle_SameInputAndOutput_RejectsWithError() { - Assert.NotNull(fixture.SelfContainedConsoleExe); + Assert.IsNotNull(Fixture.SelfContainedConsoleExe); var (exitCode, _, stderr) = await RunDotsiderAsync( - "analyze", fixture.SelfContainedConsoleExe!, "--bundle", "-o", fixture.SelfContainedConsoleExe!); + "analyze", Fixture.SelfContainedConsoleExe!, "--bundle", "-o", Fixture.SelfContainedConsoleExe!); - Assert.NotEqual(0, exitCode); + Assert.AreNotEqual(0, exitCode); Assert.Contains("Output path cannot be the same as the input file", stderr); } /// Verifies that default output for a bundle-backed assembly shows DisplayName. - [Fact] + [TestMethod] public async Task Analyze_DefaultOutput_BundleBacked_ShowsDisplayName() { - Assert.NotNull(fixture.SelfContainedConsoleExe); + Assert.IsNotNull(Fixture.SelfContainedConsoleExe); var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.SelfContainedConsoleExe!); + "analyze", Fixture.SelfContainedConsoleExe!); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("from bundle", stdout); } /// Verifies that default JSON output for a bundle-backed assembly includes bundle properties. - [Fact] + [TestMethod] public async Task Analyze_DefaultOutput_BundleBacked_Json_IncludesProperties() { - Assert.NotNull(fixture.SelfContainedConsoleExe); + Assert.IsNotNull(Fixture.SelfContainedConsoleExe); var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.SelfContainedConsoleExe!, "--json"); + "analyze", Fixture.SelfContainedConsoleExe!, "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); var json = System.Text.Json.JsonSerializer.Deserialize(stdout); - Assert.True(json.GetProperty("isBundleBacked").GetBoolean()); - Assert.Equal("SelfContainedConsole.dll", json.GetProperty("displayName").GetString()); - Assert.NotNull(json.GetProperty("preferredRuntimePack").GetString()); + Assert.IsTrue(json.GetProperty("isBundleBacked").GetBoolean()); + Assert.AreEqual("SelfContainedConsole.dll", json.GetProperty("displayName").GetString()); + Assert.IsNotNull(json.GetProperty("preferredRuntimePack").GetString()); } /// @@ -415,23 +417,23 @@ public async Task Analyze_DefaultOutput_BundleBacked_Json_IncludesProperties() /// greater than zero and edges whose source is not always the root, and that no internal /// navigation fields leak into the payload. /// - [Fact] + [TestMethod] public async Task Analyze_Deps_Json_EmitsTransitiveGraphWithoutNavigationLeak() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.RichLibraryDll, "--deps", "--json"); + "analyze", Fixture.RichLibraryDll, "--deps", "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); var root = System.Text.Json.JsonSerializer.Deserialize(stdout); - Assert.True(root.TryGetProperty("graph", out var graph)); - Assert.True(graph.TryGetProperty("nodes", out var nodes)); - Assert.True(graph.TryGetProperty("edges", out var edges)); + Assert.IsTrue(root.TryGetProperty("graph", out var graph)); + Assert.IsTrue(graph.TryGetProperty("nodes", out var nodes)); + Assert.IsTrue(graph.TryGetProperty("edges", out var edges)); string? rootId = null; var anyDepthOverZero = false; foreach (var n in nodes.EnumerateArray()) { - Assert.True(n.TryGetProperty("id", out var id)); + Assert.IsTrue(n.TryGetProperty("id", out var id)); foreach (var leak in new[] { "resolvedPath", "referencingFilePath", "referencingBundlePath", @@ -439,7 +441,7 @@ public async Task Analyze_Deps_Json_EmitsTransitiveGraphWithoutNavigationLeak() "candidateProbePath", "isFrameworkAssembly", "resolved", }) { - Assert.False(n.TryGetProperty(leak, out _), $"node must not expose {leak}"); + Assert.IsFalse(n.TryGetProperty(leak, out _), $"node must not expose {leak}"); } if (n.TryGetProperty("isRoot", out var isRoot) && isRoot.GetBoolean()) @@ -448,31 +450,31 @@ public async Task Analyze_Deps_Json_EmitsTransitiveGraphWithoutNavigationLeak() anyDepthOverZero = true; } - Assert.True(anyDepthOverZero); - Assert.NotNull(rootId); + Assert.IsTrue(anyDepthOverZero); + Assert.IsNotNull(rootId); var anyNonRootSource = false; foreach (var e in edges.EnumerateArray()) { - Assert.True(e.TryGetProperty("sourceId", out var src)); + Assert.IsTrue(e.TryGetProperty("sourceId", out var src)); if (src.GetString() != rootId) anyNonRootSource = true; } - Assert.True(anyNonRootSource); + Assert.IsTrue(anyNonRootSource); } /// /// Verifies analyze --deps --json on a Native AOT binary emits the compiled-in /// assemblies and the native import modules, with only non-default node kinds serialized. /// - [Fact] + [TestMethod] public async Task Analyze_Deps_NativeAot_Json_EmitsAssembliesAndImports() { - Assert.SkipWhen(fixture.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Fixture.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.NativeAotConsoleExe!, "--deps", "--json"); + "analyze", Fixture.NativeAotConsoleExe!, "--deps", "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("System.Private.CoreLib", stdout); Assert.Contains("\"nativeImport\"", stdout); } @@ -481,13 +483,13 @@ public async Task Analyze_Deps_NativeAot_Json_EmitsAssembliesAndImports() /// Verifies managed analyze --deps --json output is byte-compatible with the /// pre-AOT shape: the default assembly kind is never serialized. /// - [Fact] + [TestMethod] public async Task Analyze_Deps_Managed_Json_OmitsDefaultKind() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.RichLibraryDll, "--deps", "--json"); + "analyze", Fixture.RichLibraryDll, "--deps", "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.DoesNotContain("\"kind\"", stdout); } @@ -495,15 +497,15 @@ public async Task Analyze_Deps_Managed_Json_OmitsDefaultKind() /// Verifies analyze --size on a Native AOT binary with an mstat sidecar prints the /// per-assembly breakdown and the data categories instead of an empty tree. /// - [Fact] + [TestMethod] public async Task Analyze_Size_NativeAot_PrintsAssemblyBreakdown() { - Assert.SkipWhen(fixture.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Fixture.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.NativeAotConsoleExe!, "--size"); + "analyze", Fixture.NativeAotConsoleExe!, "--size"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("System.Private.CoreLib", stdout); Assert.Contains("Blobs", stdout); } @@ -512,15 +514,15 @@ public async Task Analyze_Size_NativeAot_PrintsAssemblyBreakdown() /// Verifies analyze --size --json on a Native AOT binary carries the new node /// kinds and the dependency-graph node names that make the tree joinable. /// - [Fact] + [TestMethod] public async Task Analyze_Size_NativeAot_Json_HasAotKindsAndNodeNames() { - Assert.SkipWhen(fixture.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Fixture.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.NativeAotConsoleExe!, "--size", "--json"); + "analyze", Fixture.NativeAotConsoleExe!, "--size", "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("\"category\"", stdout); Assert.Contains("aotNodeName", stdout); } @@ -529,13 +531,13 @@ public async Task Analyze_Size_NativeAot_Json_HasAotKindsAndNodeNames() /// Verifies analyze --size --json on a managed assembly is unchanged by the AOT /// additions: no aotNodeName property appears. /// - [Fact] + [TestMethod] public async Task Analyze_Size_Managed_Json_HasNoAotProperties() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.RichLibraryDll, "--size", "--json"); + "analyze", Fixture.RichLibraryDll, "--size", "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.DoesNotContain("aotNodeName", stdout); } @@ -543,15 +545,16 @@ public async Task Analyze_Size_Managed_Json_HasNoAotProperties() /// Verifies analyze --symbols on a Native AOT binary prints the provenance header /// and the symbol table. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Analyze_Symbols_NativeAot_PrintsTable() { - Assert.SkipWhen(fixture.NativeAotConsoleSymbols is null, "native symbols were not produced"); + TestSkip.When(Fixture.NativeAotConsoleSymbols is null, "native symbols were not produced"); var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.NativeAotConsoleExe!, "--symbols"); + "analyze", Fixture.NativeAotConsoleExe!, "--symbols"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("Source:", stdout); Assert.Contains("Symbols (", stdout); Assert.Contains("0x", stdout); @@ -560,15 +563,16 @@ public async Task Analyze_Symbols_NativeAot_PrintsTable() /// /// Verifies analyze --symbols --json carries the provenance and the symbol list. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Analyze_Symbols_NativeAot_Json_CarriesProvenance() { - Assert.SkipWhen(fixture.NativeAotConsoleSymbols is null, "native symbols were not produced"); + TestSkip.When(Fixture.NativeAotConsoleSymbols is null, "native symbols were not produced"); var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.NativeAotConsoleExe!, "--symbols", "--json"); + "analyze", Fixture.NativeAotConsoleExe!, "--symbols", "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("\"source\"", stdout); Assert.Contains("\"status\"", stdout); Assert.Contains("\"symbols\"", stdout); @@ -579,20 +583,22 @@ public async Task Analyze_Symbols_NativeAot_Json_CarriesProvenance() /// Verifies analyze --symbols on a managed assembly exits 1 — there are no native /// symbols to read. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Analyze_Symbols_Managed_ExitsOne() { var (exitCode, _, stderr) = await RunDotsiderAsync( - "analyze", fixture.RichLibraryDll, "--symbols"); + "analyze", Fixture.RichLibraryDll, "--symbols"); - Assert.Equal(1, exitCode); + Assert.AreEqual(1, exitCode); Assert.Contains("managed", stderr); } /// /// Verifies default and JSON analyze output report a raw SDK WebAssembly module as Wasm. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Analyze_Wasm_PrintsModuleSummary() { var wasmPath = GetWasmNativePath(); @@ -600,7 +606,7 @@ public async Task Analyze_Wasm_PrintsModuleSummary() var (exitCode, stdout, _) = await RunDotsiderAsync( "analyze", wasmPath); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("Kind: WebAssembly (.NET)", stdout); Assert.Contains("Functions:", stdout); Assert.Contains("Symbols:", stdout); @@ -608,17 +614,18 @@ public async Task Analyze_Wasm_PrintsModuleSummary() var (jsonExitCode, jsonStdout, _) = await RunDotsiderAsync( "analyze", wasmPath, "--json"); - Assert.Equal(0, jsonExitCode); + Assert.AreEqual(0, jsonExitCode); var json = System.Text.Json.JsonSerializer.Deserialize(jsonStdout); - Assert.Equal("wasm", json.GetProperty("binaryKind").GetString()); - Assert.Equal("Wasm32", json.GetProperty("architecture").GetString()); - Assert.True(json.GetProperty("wasm").GetProperty("definedFunctionCount").GetInt32() > 0); + Assert.AreEqual("wasm", json.GetProperty("binaryKind").GetString()); + Assert.AreEqual("Wasm32", json.GetProperty("architecture").GetString()); + Assert.IsGreaterThan(0, json.GetProperty("wasm").GetProperty("definedFunctionCount").GetInt32()); } /// /// Verifies analyze --symbols on a raw Wasm module prints WebAssembly provenance. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Analyze_Symbols_Wasm_PrintsTable() { var wasmPath = GetWasmNativePath(); @@ -626,7 +633,7 @@ public async Task Analyze_Symbols_Wasm_PrintsTable() var (exitCode, stdout, _) = await RunDotsiderAsync( "analyze", wasmPath, "--symbols"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("WebAssembly", stdout); Assert.Contains("Symbols (", stdout); Assert.Contains("0x", stdout); @@ -635,7 +642,8 @@ public async Task Analyze_Symbols_Wasm_PrintsTable() /// /// Verifies analyze --size on a raw Wasm module reports the Wasm function tree. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Analyze_Size_Wasm_PrintsFunctionBreakdown() { var wasmPath = GetWasmNativePath(); @@ -643,39 +651,41 @@ public async Task Analyze_Size_Wasm_PrintsFunctionBreakdown() var (exitCode, stdout, _) = await RunDotsiderAsync( "analyze", wasmPath, "--size"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("(Wasm)", stdout); Assert.Contains("Functions", stdout); } /// Verifies analyze --disasm 0xVA on a Native AOT binary prints a named listing. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Analyze_Disasm_NativeAot_ByAddress_PrintsListing() { - Assert.SkipWhen(fixture.NativeAotConsoleExe is null || !File.Exists(fixture.NativeAotConsoleExe), + TestSkip.When(Fixture.NativeAotConsoleExe is null || !File.Exists(Fixture.NativeAotConsoleExe), "NativeAOT publish did not run on this leg."); ulong va; - using (var analyzer = new Dotsider.Core.Analysis.AssemblyAnalyzer(fixture.NativeAotConsoleExe!)) + using (var analyzer = new Dotsider.Core.Analysis.AssemblyAnalyzer(Fixture.NativeAotConsoleExe!)) { var fn = analyzer.NativeSymbols?.Symbols.FirstOrDefault(s => s.Kind == Dotsider.Core.Analysis.Models.NativeSymbolKind.Function && s.ManagedName is not null && s.FileOffset is not null && s.Size > 0); - Assert.NotNull(fn); + Assert.IsNotNull(fn); va = fn!.VirtualAddress; } var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.NativeAotConsoleExe!, "--disasm", $"0x{va:x}"); + "analyze", Fixture.NativeAotConsoleExe!, "--disasm", $"0x{va:x}"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains($"0x{va:x}:", stdout); } /// /// Verifies analyze --disasm on a raw Wasm module decodes a real function body. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Analyze_Disasm_Wasm_ByAddress_PrintsListing() { var wasmPath = GetWasmNativePath(); @@ -690,7 +700,7 @@ public async Task Analyze_Disasm_Wasm_ByAddress_PrintsListing() var (exitCode, stdout, _) = await RunDotsiderAsync( "analyze", wasmPath, "--disasm", $"0x{va:x}"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains($"0x{va:x}:", stdout); Assert.Contains("call", stdout); } @@ -699,7 +709,8 @@ public async Task Analyze_Disasm_Wasm_ByAddress_PrintsListing() /// Verifies analyze --disasm accepts WebAssembly function identifiers in the forms users /// see in wasm tooling: func:N and a bare decimal function index. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Analyze_Disasm_Wasm_ByFunctionIndex_PrintsListing() { var wasmPath = GetWasmNativePath(); @@ -719,8 +730,8 @@ public async Task Analyze_Disasm_Wasm_ByFunctionIndex_PrintsListing() var (indexExitCode, indexStdout, _) = await RunDotsiderAsync( "analyze", wasmPath, "--disasm", funcIndex); - Assert.Equal(0, aliasExitCode); - Assert.Equal(0, indexExitCode); + Assert.AreEqual(0, aliasExitCode); + Assert.AreEqual(0, indexExitCode); Assert.Contains("func[", aliasStdout); Assert.Contains("func[", indexStdout); } @@ -728,29 +739,31 @@ public async Task Analyze_Disasm_Wasm_ByFunctionIndex_PrintsListing() /// /// Verifies Webcil-wrapped .wasm app assemblies use managed metadata CLI behavior. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Analyze_WebcilWasm_PrintsManagedMetadata() { - Assert.SkipWhen(fixture.WasmConsoleWebcilWasm is null, + TestSkip.When(Fixture.WasmConsoleWebcilWasm is null, "browser-wasm publish did not produce the Webcil app assembly on this leg."); var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.WasmConsoleWebcilWasm!); + "analyze", Fixture.WasmConsoleWebcilWasm!); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("Kind: Managed", stdout); Assert.Contains("Webcil:", stdout); Assert.DoesNotContain("Kind: WebAssembly (.NET)", stdout); } /// Verifies analyze --disasm on a managed assembly exits 1 with a native-symbols error. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Analyze_Disasm_Managed_ExitsOne() { var (exitCode, _, stderr) = await RunDotsiderAsync( - "analyze", fixture.RichLibraryDll, "--disasm", "Foo"); + "analyze", Fixture.RichLibraryDll, "--disasm", "Foo"); - Assert.Equal(1, exitCode); + Assert.AreEqual(1, exitCode); Assert.Contains("native symbols", stderr); } @@ -758,15 +771,16 @@ public async Task Analyze_Disasm_Managed_ExitsOne() /// Verifies the default analyze --json info carries the native symbol provenance /// fields for a Native AOT binary. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Analyze_Info_NativeAot_Json_CarriesSymbolProvenance() { - Assert.SkipWhen(fixture.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Fixture.NativeAotConsoleExe is null, "NativeAOT sample was not built"); var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.NativeAotConsoleExe!, "--json"); + "analyze", Fixture.NativeAotConsoleExe!, "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("nativeSymbolCount", stdout); Assert.Contains("nativeSymbolSource", stdout); Assert.Contains("nativeSymbolStatus", stdout); @@ -777,16 +791,16 @@ public async Task Analyze_Info_NativeAot_Json_CarriesSymbolProvenance() /// Verifies analyze --why prints the root-first dependency chain for a compiled /// method when both sidecars are present. /// - [Fact] + [TestMethod] public async Task Analyze_Why_KnownType_PrintsChain() { - Assert.SkipWhen(fixture.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - Assert.SkipWhen(fixture.NativeAotConsoleDgml is null, "DGML sidecar was not produced"); + TestSkip.When(Fixture.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Fixture.NativeAotConsoleDgml is null, "DGML sidecar was not produced"); var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.NativeAotConsoleExe!, "--why", "Program"); + "analyze", Fixture.NativeAotConsoleExe!, "--why", "Program"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("in the binary? (root first)", stdout); Assert.Contains("1.", stdout); } @@ -794,47 +808,47 @@ public async Task Analyze_Why_KnownType_PrintsChain() /// /// Verifies analyze --why --json emits the chain as structured steps. /// - [Fact] + [TestMethod] public async Task Analyze_Why_Json_EmitsChainSteps() { - Assert.SkipWhen(fixture.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - Assert.SkipWhen(fixture.NativeAotConsoleDgml is null, "DGML sidecar was not produced"); + TestSkip.When(Fixture.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Fixture.NativeAotConsoleDgml is null, "DGML sidecar was not produced"); var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.NativeAotConsoleExe!, "--why", "Program", "--json"); + "analyze", Fixture.NativeAotConsoleExe!, "--why", "Program", "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); var json = System.Text.Json.JsonSerializer.Deserialize(stdout); - Assert.True(json.GetProperty("chain").GetArrayLength() > 0); - Assert.False(string.IsNullOrEmpty(json.GetProperty("target").GetString())); + Assert.IsGreaterThan(0, json.GetProperty("chain").GetArrayLength()); + Assert.IsFalse(string.IsNullOrEmpty(json.GetProperty("target").GetString())); } /// /// Verifies analyze --why with an unknown name errors with a clear message. /// - [Fact] + [TestMethod] public async Task Analyze_Why_UnknownName_Errors() { - Assert.SkipWhen(fixture.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - Assert.SkipWhen(fixture.NativeAotConsoleDgml is null, "DGML sidecar was not produced"); + TestSkip.When(Fixture.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Fixture.NativeAotConsoleDgml is null, "DGML sidecar was not produced"); var (exitCode, _, stderr) = await RunDotsiderAsync( - "analyze", fixture.NativeAotConsoleExe!, "--why", "NoSuchThingAnywhere12345"); + "analyze", Fixture.NativeAotConsoleExe!, "--why", "NoSuchThingAnywhere12345"); - Assert.Equal(1, exitCode); + Assert.AreEqual(1, exitCode); Assert.Contains("no compiled type or method matches", stderr); } /// /// Verifies analyze --why on a managed assembly explains the sidecar requirement. /// - [Fact] + [TestMethod] public async Task Analyze_Why_ManagedAssembly_Errors() { var (exitCode, _, stderr) = await RunDotsiderAsync( - "analyze", fixture.RichLibraryDll, "--why", "Program"); + "analyze", Fixture.RichLibraryDll, "--why", "Program"); - Assert.Equal(1, exitCode); + Assert.AreEqual(1, exitCode); Assert.Contains("requires a Native AOT binary", stderr); } @@ -843,15 +857,16 @@ public async Task Analyze_Why_ManagedAssembly_Errors() /// /// Verifies plain analyze prints the pre-ILC probe summary without attaching. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Analyze_Default_PreIlc_PrintsProbeSummaryWithoutAttaching() { - Assert.SkipWhen(fixture.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Fixture.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.NativeAotConsoleExe!); + "analyze", Fixture.NativeAotConsoleExe!); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("Pre-ILC:", stdout); Assert.Contains("Origin:", stdout); // The counts summary attaches; a plain info dump must not print it. @@ -861,33 +876,35 @@ public async Task Analyze_Default_PreIlc_PrintsProbeSummaryWithoutAttaching() /// /// Verifies the default analyze --json carries the preIlc probe object. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Analyze_Default_PreIlc_Json_CarriesProbeObject() { - Assert.SkipWhen(fixture.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Fixture.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.NativeAotConsoleExe!, "--json"); + "analyze", Fixture.NativeAotConsoleExe!, "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); var json = System.Text.Json.JsonSerializer.Deserialize(stdout); var preIlc = json.GetProperty("preIlc"); - Assert.True(preIlc.GetProperty("hasAttachableCompanion").GetBoolean()); - Assert.False(string.IsNullOrEmpty(preIlc.GetProperty("managedAssemblyPath").GetString())); + Assert.IsTrue(preIlc.GetProperty("hasAttachableCompanion").GetBoolean()); + Assert.IsFalse(string.IsNullOrEmpty(preIlc.GetProperty("managedAssemblyPath").GetString())); } /// /// Verifies bare --correlate attaches and prints the correlation counts. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Analyze_Correlate_Bare_PrintsCounts() { - Assert.SkipWhen(fixture.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Fixture.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.NativeAotConsoleExe!, "--correlate"); + "analyze", Fixture.NativeAotConsoleExe!, "--correlate"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("Correlation:", stdout); Assert.Contains("methods", stdout); } @@ -895,15 +912,16 @@ public async Task Analyze_Correlate_Bare_PrintsCounts() /// /// Verifies --correlate Type.Method resolves a unique method and prints its IL. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Analyze_Correlate_ByName_PrintsIl() { - Assert.SkipWhen(fixture.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Fixture.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.NativeAotConsoleExe!, "--correlate", "Greeter.Describe"); + "analyze", Fixture.NativeAotConsoleExe!, "--correlate", "Greeter.Describe"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("Greeter::Describe", stdout); Assert.Contains("--- IL (pre-ILC) ---", stdout); } @@ -911,43 +929,45 @@ public async Task Analyze_Correlate_ByName_PrintsIl() /// /// Verifies --correlate 0xVA resolves by address and prints the native listing. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Analyze_Correlate_ByAddress_PrintsNative() { - Assert.SkipWhen(fixture.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); - Assert.SkipWhen(fixture.NativeAotConsoleSymbols is null, "native symbols were not produced"); + TestSkip.When(Fixture.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Fixture.NativeAotConsoleSymbols is null, "native symbols were not produced"); ulong va; - using (var analyzer = new Dotsider.Core.Analysis.AssemblyAnalyzer(fixture.NativeAotConsoleExe!)) + using (var analyzer = new Dotsider.Core.Analysis.AssemblyAnalyzer(Fixture.NativeAotConsoleExe!)) { analyzer.AttachPreIlcCompanions(); var correlation = analyzer.ManagedNativeIndex?.Methods.FirstOrDefault(m => m.Status == Dotsider.Core.Analysis.Models.MethodCorrelationStatus.CorrelatedExact && m.NativeSymbols.Count > 0 && m.NativeSymbols[0].FileOffset is not null); - Assert.NotNull(correlation); + Assert.IsNotNull(correlation); va = correlation!.NativeSymbols[0].VirtualAddress; } var (exitCode, stdout, _) = await RunDotsiderAsync( - "analyze", fixture.NativeAotConsoleExe!, "--correlate", $"0x{va:x}"); + "analyze", Fixture.NativeAotConsoleExe!, "--correlate", $"0x{va:x}"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("--- Native ---", stdout); } /// /// Verifies an ambiguous name (overloads) lists every candidate and exits non-zero. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Analyze_Correlate_AmbiguousName_ListsCandidatesAndExitsNonZero() { - Assert.SkipWhen(fixture.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Fixture.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); var (exitCode, _, stderr) = await RunDotsiderAsync( - "analyze", fixture.NativeAotConsoleExe!, "--correlate", "Greeter.Greet"); + "analyze", Fixture.NativeAotConsoleExe!, "--correlate", "Greeter.Greet"); - Assert.Equal(2, exitCode); + Assert.AreEqual(2, exitCode); Assert.Contains("ambiguous", stderr); Assert.Contains("Greeter::Greet", stderr); } @@ -955,13 +975,14 @@ public async Task Analyze_Correlate_AmbiguousName_ListsCandidatesAndExitsNonZero /// /// Verifies --correlate on a managed assembly explains the Native AOT requirement. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Analyze_Correlate_ManagedAssembly_ExitsOne() { var (exitCode, _, stderr) = await RunDotsiderAsync( - "analyze", fixture.RichLibraryDll, "--correlate", "Foo"); + "analyze", Fixture.RichLibraryDll, "--correlate", "Foo"); - Assert.Equal(1, exitCode); + Assert.AreEqual(1, exitCode); Assert.Contains("requires a Native AOT binary", stderr); } @@ -974,7 +995,7 @@ private static Dotsider.Core.Analysis.Models.NativeSymbol FindWasmFunctionWithNa Dotsider.Core.Analysis.AssemblyAnalyzer analyzer) { var info = analyzer.NativeSymbols; - Assert.NotNull(info); + Assert.IsNotNull(info); foreach (var symbol in info.Symbols.Take(512)) { var result = Dotsider.Core.Analysis.Disasm.NativeDisassembler.DisassembleSymbol(analyzer, symbol); @@ -988,11 +1009,11 @@ private static Dotsider.Core.Analysis.Models.NativeSymbol FindWasmFunctionWithNa throw new InvalidOperationException("No Wasm function with a named direct call was found."); } - private string GetWasmNativePath() + private static string GetWasmNativePath() { - Assert.SkipWhen(fixture.WasmConsoleNativeWasm is null && fixture.ReadyToRunConsoleWasmNativeWasm is null, + TestSkip.When(Fixture.WasmConsoleNativeWasm is null && Fixture.ReadyToRunConsoleWasmNativeWasm is null, "browser-wasm publish did not run on this leg."); - return fixture.WasmConsoleNativeWasm ?? fixture.ReadyToRunConsoleWasmNativeWasm!; + return Fixture.WasmConsoleNativeWasm ?? Fixture.ReadyToRunConsoleWasmNativeWasm!; } } diff --git a/tests/Dotsider.Tests/ConnectionLimitTests.cs b/tests/Dotsider.Tests/ConnectionLimitTests.cs index 85db7c72..336c51d7 100644 --- a/tests/Dotsider.Tests/ConnectionLimitTests.cs +++ b/tests/Dotsider.Tests/ConnectionLimitTests.cs @@ -12,14 +12,18 @@ namespace Dotsider.Tests; /// Tests that the connection limit (4 concurrent) works correctly. /// Uses the full headless TUI stack with real assemblies. /// -[Collection("SampleAssemblies")] -public class ConnectionLimitTests(SampleAssemblyFixture samples) : IAsyncDisposable +[TestClass] +public class ConnectionLimitTests : IAsyncDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _app; private DotsiderState? _state; private DotsiderDiagnosticsListener? _listener; + private CancellationTokenSource? _appCts; + private Task? _appTask; private async Task StartTuiWithDiagnosticsAsync(CancellationToken ct) { @@ -35,7 +39,7 @@ private async Task StartTuiWithDiagnosticsAsync(CancellationToken ct) _app = new Hex1bApp( ctx => { - _state ??= new DotsiderState(_app!, samples.HelloWorldDll, pendingMutations); + _state ??= new DotsiderState(_app!, Samples.HelloWorldDll, pendingMutations); var dotsiderApp = new DotsiderApp(_state); return Task.FromResult(dotsiderApp.Build(ctx)); }, @@ -43,12 +47,13 @@ private async Task StartTuiWithDiagnosticsAsync(CancellationToken ct) { WorkloadAdapter = _workload, EnableInputCoalescing = false - }); + }); _listener = new DotsiderDiagnosticsListener(() => _state); - _listener.StartListening(overridePid: Random.Shared.Next(100_000, 999_999)); + _listener.StartListening(overridePid: TestSocketIds.NextPid()); - _ = _app.RunAsync(ct); + _appCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _appTask = _app.RunAsync(_appCts.Token); await Task.Delay(100, ct); await TestHelpers.WaitUntilAsync( @@ -64,24 +69,32 @@ await TestHelpers.WaitUntilAsync( public async ValueTask DisposeAsync() { GC.SuppressFinalize(this); + _appCts?.Cancel(); if (_listener is not null) { _listener.TestDelayHook = null; await _listener.DisposeAsync(); } + if (_appTask is not null) + { + try { await _appTask; } + catch (OperationCanceledException) { } + } _state?.Dispose(); _app?.Dispose(); if (_terminal is not null) await _terminal.DisposeAsync(); + _appCts?.Dispose(); } /// /// Verifies four connections all succeed. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task FourConnections_AllSucceed() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var socketPath = await StartTuiWithDiagnosticsAsync(ct); // Fire 4 concurrent requests — all should succeed @@ -91,16 +104,17 @@ public async Task FourConnections_AllSucceed() .ToList(); var responses = await Task.WhenAll(tasks); - Assert.All(responses, r => Assert.True(r.Success)); + TestAssert.All(responses, r => Assert.IsTrue(r.Success)); } /// /// Verifies fifth connection is rejected. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task FifthConnection_IsRejected() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var socketPath = await StartTuiWithDiagnosticsAsync(ct); // Use the test delay hook to hold 4 slots open @@ -128,7 +142,7 @@ public async Task FifthConnection_IsRejected() var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "assembly-info" }, ct); - Assert.False(response.Success); + Assert.IsFalse(response.Success); Assert.Contains("too many", response.Error!, StringComparison.OrdinalIgnoreCase); // Release the held connections @@ -143,10 +157,11 @@ public async Task FifthConnection_IsRejected() /// /// Verifies slot freed allows new connection. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SlotFreed_AllowsNewConnection() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var socketPath = await StartTuiWithDiagnosticsAsync(ct); // Hold 4 slots with a controllable hook @@ -186,16 +201,17 @@ public async Task SlotFreed_AllowsNewConnection() var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "assembly-info" }, ct); - Assert.True(response.Success); + Assert.IsTrue(response.Success); } /// /// Verifies stalled client times out. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task StalledClient_TimesOut() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var socketPath = await StartTuiWithDiagnosticsAsync(ct); // Connect but never send a newline — the read timeout (5s) should free the slot @@ -209,16 +225,17 @@ public async Task StalledClient_TimesOut() var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "assembly-info" }, ct); - Assert.True(response.Success); + Assert.IsTrue(response.Success); } /// /// Verifies shutdown with active connections completes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ShutdownWithActiveConnections_Completes() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var socketPath = await StartTuiWithDiagnosticsAsync(ct); // Hold a connection slot open @@ -242,7 +259,7 @@ public async Task ShutdownWithActiveConnections_Completes() var disposeTask = _listener.DisposeAsync().AsTask(); var completed = await Task.WhenAny(disposeTask, Task.Delay(10_000, ct)); - Assert.Same(disposeTask, completed); + Assert.AreSame(disposeTask, completed); // Prevent double-dispose in DisposeAsync _listener = null; diff --git a/tests/Dotsider.Tests/DataInterpretationPanelTests.cs b/tests/Dotsider.Tests/DataInterpretationPanelTests.cs index 0b6ac2cd..5ea0a482 100644 --- a/tests/Dotsider.Tests/DataInterpretationPanelTests.cs +++ b/tests/Dotsider.Tests/DataInterpretationPanelTests.cs @@ -7,9 +7,11 @@ namespace Dotsider.Tests; /// /// Tests for Data Interpretation Panel. /// -[Collection("SampleAssemblies")] -public class DataInterpretationPanelTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class DataInterpretationPanelTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _hex1bApp; @@ -45,11 +47,12 @@ public class DataInterpretationPanelTests(SampleAssemblyFixture samples) : IDisp /// /// Verifies hex dump tab shows interpretation labels. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task HexDumpTab_ShowsInterpretationLabels() { - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); - var ct = TestContext.Current.CancellationToken; + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); @@ -69,11 +72,12 @@ public async Task HexDumpTab_ShowsInterpretationLabels() /// /// Verifies hex dump tab shows endian label. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task HexDumpTab_ShowsEndianLabel() { - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); - var ct = TestContext.Current.CancellationToken; + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); @@ -91,11 +95,12 @@ public async Task HexDumpTab_ShowsEndianLabel() /// /// Verifies hex dump tab shows length matching file size. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task HexDumpTab_ShowsLengthMatchingFileSize() { - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); - var ct = TestContext.Current.CancellationToken; + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); @@ -107,8 +112,8 @@ public async Task HexDumpTab_ShowsLengthMatchingFileSize() .ApplyAsync(terminal, ct); // Verify the length in the panel matches the actual file size - Assert.NotNull(_state); - Assert.Equal(_state.Analyzer.RawBytes.Length, _state.HexEditorState.Document.ByteCount); + Assert.IsNotNull(_state); + Assert.AreEqual(_state.Analyzer.RawBytes.Length, _state.HexEditorState.Document.ByteCount); await runTask.ContinueWith(_ => { }, ct); } @@ -116,11 +121,12 @@ public async Task HexDumpTab_ShowsLengthMatchingFileSize() /// /// Verifies hex dump tab shows hex addresses. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task HexDumpTab_ShowsHexAddresses() { - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); - var ct = TestContext.Current.CancellationToken; + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); @@ -138,11 +144,12 @@ public async Task HexDumpTab_ShowsHexAddresses() /// /// Verifies hex dump tab endian toggle updates values. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task HexDumpTab_EndianToggleUpdatesValues() { - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); - var ct = TestContext.Current.CancellationToken; + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); @@ -161,7 +168,7 @@ public async Task HexDumpTab_EndianToggleUpdatesValues() .Build() .ApplyAsync(terminal, ct); - Assert.Equal(HexEndianness.Big, _state!.HexEndianness); + Assert.AreEqual(HexEndianness.Big, _state!.HexEndianness); await runTask.ContinueWith(_ => { }, ct); } @@ -169,11 +176,12 @@ public async Task HexDumpTab_EndianToggleUpdatesValues() /// /// Verifies hex dump tab cursor move updates interpretation. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task HexDumpTab_CursorMoveUpdatesInterpretation() { - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); - var ct = TestContext.Current.CancellationToken; + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); @@ -184,7 +192,7 @@ public async Task HexDumpTab_CursorMoveUpdatesInterpretation() .ApplyAsync(terminal, ct); var textBefore = _state!.DataInterpEditorText; - Assert.NotNull(textBefore); + Assert.IsNotNull(textBefore); // Move cursor right — byte value changes await new Hex1bTerminalInputSequenceBuilder() @@ -194,7 +202,7 @@ public async Task HexDumpTab_CursorMoveUpdatesInterpretation() .ApplyAsync(terminal, ct); var textAfter = _state!.DataInterpEditorText; - Assert.NotEqual(textBefore, textAfter); + Assert.AreNotEqual(textBefore, textAfter); await new Hex1bTerminalInputSequenceBuilder() .Ctrl().Key(Hex1bKey.C) @@ -209,10 +217,10 @@ public async Task HexDumpTab_CursorMoveUpdatesInterpretation() /// public void Dispose() { + GC.SuppressFinalize(this); _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/DependencyGraphBuilderTests.cs b/tests/Dotsider.Tests/DependencyGraphBuilderTests.cs index 9b002d47..af3209ce 100644 --- a/tests/Dotsider.Tests/DependencyGraphBuilderTests.cs +++ b/tests/Dotsider.Tests/DependencyGraphBuilderTests.cs @@ -9,67 +9,75 @@ namespace Dotsider.Tests; /// TypeRef counts by full identity, determinism, and behavior across bundle, apphost, and /// native deployment shapes. /// -[Collection("SampleAssemblies")] -public class DependencyGraphBuilderTests(SampleAssemblyFixture samples) +[TestClass] +public class DependencyGraphBuilderTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// HelloWorld produces a root node marked IsRoot. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HelloWorld_HasRootNode() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); var graph = DependencyGraphBuilder.Build(a); - Assert.NotEmpty(graph.Nodes); - Assert.Single(graph.Nodes, n => n.IsRoot); + Assert.IsNotEmpty(graph.Nodes); + Assert.ContainsSingle(n => n.IsRoot, graph.Nodes); } /// The root node's name matches the analyzed assembly name. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HelloWorld_RootNodeNameMatchesAssembly() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); var graph = DependencyGraphBuilder.Build(a); - Assert.Equal("HelloWorld", graph.Nodes.First(n => n.IsRoot).Name); + Assert.AreEqual("HelloWorld", graph.Nodes.First(n => n.IsRoot).Name); } /// RichLibrary still sees its direct third-party references as nodes. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_HasDirectReferenceNode_NewtonSoft() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var graph = DependencyGraphBuilder.Build(a); - Assert.Contains(graph.Nodes, n => n.Name == "Newtonsoft.Json"); + Assert.Contains(n => n.Name == "Newtonsoft.Json", graph.Nodes); } /// /// RichLibrary's graph has nodes at depth greater than one, proving the traversal walks /// past the root's direct references into transitive references. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_HasNodesAtDepthGreaterThanOne() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var graph = DependencyGraphBuilder.Build(a); - Assert.Contains(graph.Nodes, n => n.Depth > 1); + Assert.Contains(n => n.Depth > 1, graph.Nodes); } /// /// RichLibrary's graph contains edges whose source is not the root — direct evidence that /// the builder is emitting transitive child-to-child edges, not only root-to-child. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_HasEdgesWhereSourceIsNotRoot() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var graph = DependencyGraphBuilder.Build(a); var rootId = graph.Nodes.First(n => n.IsRoot).Id; - Assert.Contains(graph.Edges, e => e.SourceId != rootId); + Assert.Contains(e => e.SourceId != rootId, graph.Edges); } /// Every edge references a node id that exists in the node set. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AllEdgesReferenceExistingNodesById() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var graph = DependencyGraphBuilder.Build(a); var ids = graph.Nodes.Select(n => n.Id).ToHashSet(StringComparer.Ordinal); foreach (var e in graph.Edges) @@ -80,62 +88,67 @@ public void AllEdgesReferenceExistingNodesById() } /// Node ids are unique across the graph. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NoDuplicateNodeIds() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var graph = DependencyGraphBuilder.Build(a); var ids = graph.Nodes.Select(n => n.Id).ToList(); - Assert.Equal(ids.Count, ids.Distinct(StringComparer.Ordinal).Count()); + Assert.HasCount(ids.Count, ids.Distinct(StringComparer.Ordinal)); } /// Exactly one root node. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void OnlyOneRootNode() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var graph = DependencyGraphBuilder.Build(a); - Assert.Single(graph.Nodes, n => n.IsRoot); + Assert.ContainsSingle(n => n.IsRoot, graph.Nodes); } /// At least one transitive edge has a positive TypeRef count. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TransitiveEdgesCarryTypeRefCounts() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var graph = DependencyGraphBuilder.Build(a); - Assert.Contains(graph.Edges, e => e.TypeRefCount > 0); + Assert.Contains(e => e.TypeRefCount > 0, graph.Edges); } /// /// Every non-root node carries navigation context keyed by its id, and the root entry /// has . /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigationById_CoversEveryNode_AndRootIsRoot() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); var graph = DependencyGraphBuilder.Build(a); foreach (var n in graph.Nodes) - Assert.True(graph.NavigationById.ContainsKey(n.Id), $"missing nav for {n.Id}"); + Assert.IsTrue(graph.NavigationById.ContainsKey(n.Id), $"missing nav for {n.Id}"); var root = graph.Nodes.First(n => n.IsRoot); - Assert.Equal(AssemblyProvenance.Root, graph.NavigationById[root.Id].Provenance); + Assert.AreEqual(AssemblyProvenance.Root, graph.NavigationById[root.Id].Provenance); } /// Building the same graph twice produces identical node id order and edge lists. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Determinism_NodesAndEdgesOrderStableAcrossRuns() { - using var a1 = new AssemblyAnalyzer(samples.RichLibraryDll); - using var a2 = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a1 = new AssemblyAnalyzer(Samples.RichLibraryDll); + using var a2 = new AssemblyAnalyzer(Samples.RichLibraryDll); var g1 = DependencyGraphBuilder.Build(a1); var g2 = DependencyGraphBuilder.Build(a2); - Assert.Equal>( + Assert.AreSequenceEqual( [.. g1.Nodes.Select(n => n.Id)], [.. g2.Nodes.Select(n => n.Id)]); - Assert.Equal>( + Assert.AreSequenceEqual( [.. g1.Edges.Select(e => (e.SourceId, e.TargetId))], [.. g2.Edges.Select(e => (e.SourceId, e.TargetId))]); } @@ -144,7 +157,8 @@ [.. g1.Edges.Select(e => (e.SourceId, e.TargetId))], /// A reference to an assembly that cannot be located anywhere produces an unresolved leaf /// with set and provenance . /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void UnresolvedRef_AddedAsLeafWithUnresolvedFlag() { using var scope = SyntheticAssemblyScope.Create(); @@ -154,9 +168,9 @@ public void UnresolvedRef_AddedAsLeafWithUnresolvedFlag() var graph = DependencyGraphBuilder.Build(a); var leaf = graph.Nodes.FirstOrDefault(n => n.Name == "NonExistentTarget_ZZZ"); - Assert.NotNull(leaf); - Assert.True(leaf!.Unresolved); - Assert.Equal(AssemblyProvenance.Unresolved, + Assert.IsNotNull(leaf); + Assert.IsTrue(leaf!.Unresolved); + Assert.AreEqual(AssemblyProvenance.Unresolved, graph.NavigationById[leaf.Id].Provenance); } @@ -166,7 +180,8 @@ public void UnresolvedRef_AddedAsLeafWithUnresolvedFlag() /// and must not expand from the mismatched /// file. The candidate probe path is recorded on the navigation context for diagnostics. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void IdentityMismatch_DoesNotGraftSubtree() { using var scope = SyntheticAssemblyScope.Create(); @@ -185,18 +200,19 @@ public void IdentityMismatch_DoesNotGraftSubtree() var graph = DependencyGraphBuilder.Build(a); var mis = graph.Nodes.FirstOrDefault(n => n.Name == "MisIdent"); - Assert.NotNull(mis); - Assert.True(mis!.Unresolved); - Assert.Equal(AssemblyProvenance.IdentityMismatch, + Assert.IsNotNull(mis); + Assert.IsTrue(mis!.Unresolved); + Assert.AreEqual(AssemblyProvenance.IdentityMismatch, graph.NavigationById[mis.Id].Provenance); - Assert.DoesNotContain(graph.Nodes, n => n.Name == "ChildOfMis"); - Assert.NotNull(graph.NavigationById[mis.Id].CandidateProbePath); + Assert.DoesNotContain(n => n.Name == "ChildOfMis", graph.Nodes); + Assert.IsNotNull(graph.NavigationById[mis.Id].CandidateProbePath); } /// /// Two references with the same simple name but different versions produce two distinct nodes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DuplicateSimpleName_DifferentVersion_ProducesTwoNodes() { using var scope = SyntheticAssemblyScope.Create(); @@ -212,14 +228,15 @@ public void DuplicateSimpleName_DifferentVersion_ProducesTwoNodes() var graph = DependencyGraphBuilder.Build(a); var targets = graph.Nodes.Where(n => n.Name == "TargetLib").ToList(); - Assert.Equal(2, targets.Count); - Assert.NotEqual(targets[0].Id, targets[1].Id); + Assert.HasCount(2, targets); + Assert.AreNotEqual(targets[0].Id, targets[1].Id); } /// /// Two references with the same simple name but different public key tokens produce two distinct nodes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DuplicateSimpleName_DifferentPublicKeyToken_ProducesTwoNodes() { using var scope = SyntheticAssemblyScope.Create(); @@ -235,8 +252,8 @@ public void DuplicateSimpleName_DifferentPublicKeyToken_ProducesTwoNodes() var graph = DependencyGraphBuilder.Build(a); var targets = graph.Nodes.Where(n => n.Name == "TargetPktLib").ToList(); - Assert.Equal(2, targets.Count); - Assert.NotEqual(targets[0].Id, targets[1].Id); + Assert.HasCount(2, targets); + Assert.AreNotEqual(targets[0].Id, targets[1].Id); } /// @@ -244,7 +261,8 @@ public void DuplicateSimpleName_DifferentPublicKeyToken_ProducesTwoNodes() /// the full identity of that AssemblyRef, so an edge's TypeRefCount is meaningful even when /// two refs share a simple name. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TypeRefCount_GroupsByFullIdentity_NotSimpleName() { using var scope = SyntheticAssemblyScope.Create(); @@ -272,8 +290,8 @@ public void TypeRefCount_GroupsByFullIdentity_NotSimpleName() var edgeToV1 = graph.Edges.Single(e => e.SourceId == rootId && e.TargetId == v1Id); var edgeToV2 = graph.Edges.Single(e => e.SourceId == rootId && e.TargetId == v2Id); - Assert.Equal(2, edgeToV1.TypeRefCount); - Assert.Equal(1, edgeToV2.TypeRefCount); + Assert.AreEqual(2, edgeToV1.TypeRefCount); + Assert.AreEqual(1, edgeToV2.TypeRefCount); } /// @@ -285,37 +303,39 @@ public void TypeRefCount_GroupsByFullIdentity_NotSimpleName() /// AssemblyRef points at Microsoft.Diagnostics.NETCore.Client v0.2.10.10501 while NuGet /// restored v0.2.13.11903 next to it. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AppLocalRollForward_FrameworkPkt_HigherVersion_ResolvesAndRecordsRequestedIdentity() { - using var a = new AssemblyAnalyzer(samples.AppLocalRollForwardDll); + using var a = new AssemblyAnalyzer(Samples.AppLocalRollForwardDll); var graph = DependencyGraphBuilder.Build(a); var nodes = graph.Nodes .Where(n => n.Name == "Microsoft.Diagnostics.NETCore.Client") .ToList(); - var node = Assert.Single(nodes); - Assert.False(node.Unresolved); + var node = Assert.ContainsSingle(nodes); + Assert.IsFalse(node.Unresolved); var nav = graph.NavigationById[node.Id]; - Assert.Equal(AssemblyProvenance.AppLocal, nav.Provenance); - Assert.NotNull(nav.LoadedIdentity); - Assert.Equal(node.Version, nav.LoadedIdentity!.Version); + Assert.AreEqual(AssemblyProvenance.AppLocal, nav.Provenance); + Assert.IsNotNull(nav.LoadedIdentity); + Assert.AreEqual(node.Version, nav.LoadedIdentity!.Version); var traceEventNode = graph.Nodes.Single( n => n.Name == "Microsoft.Diagnostics.Tracing.TraceEvent"); var edge = graph.Edges.Single( e => e.SourceId == traceEventNode.Id && e.TargetId == node.Id); - Assert.NotNull(edge.RequestedIdentity); - Assert.NotEqual(node.Version, edge.RequestedIdentity!.Version); - Assert.Equal("31bf3856ad364e35", edge.RequestedIdentity.PublicKeyToken); + Assert.IsNotNull(edge.RequestedIdentity); + Assert.AreNotEqual(node.Version, edge.RequestedIdentity!.Version); + Assert.AreEqual("31bf3856ad364e35", edge.RequestedIdentity.PublicKeyToken); } /// /// When a reference forms a cycle back to an ancestor, the cycle-closing edge is emitted but /// the ancestor is not re-expanded. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Cycle_EdgeEmittedButNotRecursed() { using var scope = SyntheticAssemblyScope.Create(); @@ -329,17 +349,18 @@ public void Cycle_EdgeEmittedButNotRecursed() var cycA = graph.Nodes.Single(n => n.Name == "CycA"); var cycB = graph.Nodes.Single(n => n.Name == "CycB"); - Assert.Contains(graph.Edges, e => e.SourceId == cycA.Id && e.TargetId == cycB.Id); - Assert.Contains(graph.Edges, e => e.SourceId == cycB.Id && e.TargetId == cycA.Id); - Assert.Equal(1, graph.Nodes.Count(n => n.Name == "CycA")); - Assert.Equal(1, graph.Nodes.Count(n => n.Name == "CycB")); + Assert.Contains(e => e.SourceId == cycA.Id && e.TargetId == cycB.Id, graph.Edges); + Assert.Contains(e => e.SourceId == cycB.Id && e.TargetId == cycA.Id, graph.Edges); + Assert.ContainsSingle(n => n.Name == "CycA", graph.Nodes); + Assert.ContainsSingle(n => n.Name == "CycB", graph.Nodes); } /// /// When two different parents reference the same child, the child appears once (merged on /// identity) and both parent-to-child edges are emitted. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Diamond_MergedOnIdentity() { using var scope = SyntheticAssemblyScope.Create(); @@ -356,9 +377,9 @@ public void Diamond_MergedOnIdentity() using var a = new AssemblyAnalyzer(rootPath); var graph = DependencyGraphBuilder.Build(a); - Assert.Equal(1, graph.Nodes.Count(n => n.Name == "DiaCommon")); + Assert.ContainsSingle(n => n.Name == "DiaCommon", graph.Nodes); var commonId = graph.Nodes.Single(n => n.Name == "DiaCommon").Id; - Assert.Equal(2, graph.Edges.Count(e => e.TargetId == commonId)); + Assert.AreEqual(2, graph.Edges.Count(e => e.TargetId == commonId)); } /// @@ -366,22 +387,23 @@ public void Diamond_MergedOnIdentity() /// assemblies as nodes, DGML links aggregated to assembly-pair edges, and the native /// import modules at depth 1. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeAot_BuildsAssemblyAndImportGraph() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - using var a = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var a = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var graph = DependencyGraphBuilder.Build(a); - var root = Assert.Single(graph.Nodes, n => n.IsRoot); - Assert.Equal("NativeAotConsole", root.Name); - Assert.Contains(graph.Nodes, n => - n.Kind == GraphNodeKind.Assembly && n.Name == "System.Private.CoreLib"); - Assert.Contains(graph.Nodes, n => n.Kind == GraphNodeKind.NativeImport); + var root = Assert.ContainsSingle(n => n.IsRoot, graph.Nodes); + Assert.AreEqual("NativeAotConsole", root.Name); + Assert.Contains(n => + n.Kind == GraphNodeKind.Assembly && n.Name == "System.Private.CoreLib", graph.Nodes); + Assert.Contains(n => n.Kind == GraphNodeKind.NativeImport, graph.Nodes); - if (samples.NativeAotConsoleDgml is not null) - Assert.Contains(graph.Edges, e => e.SourceId == root.Id && e.TypeRefCount > 0); + if (Samples.NativeAotConsoleDgml is not null) + Assert.Contains(e => e.SourceId == root.Id && e.TypeRefCount > 0, graph.Edges); } /// @@ -389,17 +411,18 @@ public void NativeAot_BuildsAssemblyAndImportGraph() /// and their navigation contexts classify framework assemblies so the hide-framework /// filter works on the AOT graph. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeAot_AssemblyNodesCarryIdentityAndFrameworkClassification() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - using var a = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var a = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var graph = DependencyGraphBuilder.Build(a); var corelib = graph.Nodes.First(n => n.Name == "System.Private.CoreLib"); - Assert.NotNull(corelib.Version); - Assert.True(graph.NavigationById[corelib.Id].IsFrameworkAssembly); + Assert.IsNotNull(corelib.Version); + Assert.IsTrue(graph.NavigationById[corelib.Id].IsFrameworkAssembly); } /// @@ -407,46 +430,48 @@ public void NativeAot_AssemblyNodesCarryIdentityAndFrameworkClassification() /// compiled-into-native-image provenance, so Enter degrades to a message instead of /// reporting a missing context. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeAot_AllNodesHaveDegradedNavigationContexts() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - using var a = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var a = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var graph = DependencyGraphBuilder.Build(a); - Assert.All(graph.Nodes, n => + TestAssert.All(graph.Nodes, n => { - Assert.True(graph.NavigationById.ContainsKey(n.Id), $"{n.Name}: navigation context missing"); - Assert.Null(graph.NavigationById[n.Id].Resolved); + Assert.IsTrue(graph.NavigationById.ContainsKey(n.Id), $"{n.Name}: navigation context missing"); + Assert.IsNull(graph.NavigationById[n.Id].Resolved); }); - Assert.All(graph.Nodes.Where(n => !n.IsRoot), n => - Assert.Equal(AssemblyProvenance.CompiledIntoNativeImage, graph.NavigationById[n.Id].Provenance)); + TestAssert.All(graph.Nodes.Where(n => !n.IsRoot), n => + Assert.AreEqual(AssemblyProvenance.CompiledIntoNativeImage, graph.NavigationById[n.Id].Provenance)); } /// /// A Native AOT binary with no sidecars falls back to the import star: the root plus its /// native import modules, every edge sourced from the root. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeAot_WithoutSidecars_ReturnsRootPlusImportStar() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null || !File.Exists(samples.NativeAotConsoleExe), + TestSkip.When(Samples.NativeAotConsoleExe is null || !File.Exists(Samples.NativeAotConsoleExe), "Native AOT sample is not available on this platform."); var dir = Directory.CreateTempSubdirectory("dotsider-depgraph-"); try { - var exeCopy = Path.Combine(dir.FullName, Path.GetFileName(samples.NativeAotConsoleExe!)); - File.Copy(samples.NativeAotConsoleExe!, exeCopy); + var exeCopy = Path.Combine(dir.FullName, Path.GetFileName(Samples.NativeAotConsoleExe!)); + File.Copy(Samples.NativeAotConsoleExe!, exeCopy); using var a = new AssemblyAnalyzer(exeCopy); var graph = DependencyGraphBuilder.Build(a); - var root = Assert.Single(graph.Nodes, n => n.IsRoot); - Assert.All(graph.Nodes.Where(n => !n.IsRoot), n => - Assert.Equal(GraphNodeKind.NativeImport, n.Kind)); - Assert.All(graph.Edges, e => Assert.Equal(root.Id, e.SourceId)); + var root = Assert.ContainsSingle(n => n.IsRoot, graph.Nodes); + TestAssert.All(graph.Nodes.Where(n => !n.IsRoot), n => + Assert.AreEqual(GraphNodeKind.NativeImport, n.Kind)); + TestAssert.All(graph.Edges, e => Assert.AreEqual(root.Id, e.SourceId)); } finally { @@ -459,29 +484,31 @@ public void NativeAot_WithoutSidecars_ReturnsRootPlusImportStar() /// produces a root-only graph and does not throw — the guarantee the AOT branch must not /// disturb. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeNonAot_ReturnsRootOnlyGraph() { - using var a = new AssemblyAnalyzer(samples.HelloWorldExe); - Assert.Equal(BinaryKind.Native, a.BinaryKind); + using var a = new AssemblyAnalyzer(Samples.HelloWorldExe); + Assert.AreEqual(BinaryKind.Native, a.BinaryKind); var graph = DependencyGraphBuilder.Build(a); - Assert.Single(graph.Nodes); - Assert.True(graph.Nodes[0].IsRoot); - Assert.Empty(graph.Edges); + Assert.ContainsSingle(graph.Nodes); + Assert.IsTrue(graph.Nodes[0].IsRoot); + Assert.IsEmpty(graph.Edges); } /// /// A bundle-backed analyzer opened via still yields a transitive /// graph without throwing when the builder walks through bundle-extracted refs. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void BundleBacked_Traversal_Works() { - Assert.SkipWhen(samples.SelfContainedConsoleExe is null || !File.Exists(samples.SelfContainedConsoleExe), + TestSkip.When(Samples.SelfContainedConsoleExe is null || !File.Exists(Samples.SelfContainedConsoleExe), "Self-contained sample is not available on this platform."); - var result = AssemblyLoader.Open(samples.SelfContainedConsoleExe!); + var result = AssemblyLoader.Open(Samples.SelfContainedConsoleExe!); using var analyzer = result switch { AssemblyOpenResult.Direct d => d.Analyzer, @@ -491,8 +518,8 @@ public void BundleBacked_Traversal_Works() }; var graph = DependencyGraphBuilder.Build(analyzer); - Assert.Contains(graph.Nodes, n => n.IsRoot); - Assert.Contains(graph.Nodes, n => n.Depth > 0); + Assert.Contains(n => n.IsRoot, graph.Nodes); + Assert.Contains(n => n.Depth > 0, graph.Nodes); } /// @@ -500,15 +527,16 @@ public void BundleBacked_Traversal_Works() /// they are resolved outside of the shared runtime directory (e.g., from app-local copies /// in a self-contained publish). /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FrameworkAssemblies_AreClassifiedAcrossDeploymentModels() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var graph = DependencyGraphBuilder.Build(a); var systemRuntime = graph.Nodes.FirstOrDefault(n => n.Name == "System.Runtime"); - Assert.NotNull(systemRuntime); - Assert.True(graph.NavigationById[systemRuntime!.Id].IsFrameworkAssembly); + Assert.IsNotNull(systemRuntime); + Assert.IsTrue(graph.NavigationById[systemRuntime!.Id].IsFrameworkAssembly); } } diff --git a/tests/Dotsider.Tests/DependencyGraphRenderLayoutTests.cs b/tests/Dotsider.Tests/DependencyGraphRenderLayoutTests.cs index 5079ea3f..91460595 100644 --- a/tests/Dotsider.Tests/DependencyGraphRenderLayoutTests.cs +++ b/tests/Dotsider.Tests/DependencyGraphRenderLayoutTests.cs @@ -10,27 +10,28 @@ namespace Dotsider.Tests; /// every scope, framework, or viewport change, prunes islands created by mid-chain filtering, /// and resolves a single hover winner across overlapping or adjacent boxes. /// -[Collection("SampleAssemblies")] -public sealed class DependencyGraphRenderLayoutTests(SampleAssemblyFixture samples) +[TestClass] +public sealed class DependencyGraphRenderLayoutTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// Toggling scope from All to DirectOnly rebalances the rendered layout — /// depth-1 nodes move (and deep-band nodes disappear) because the layout is computed /// from the visible subgraph, not from stale full-graph coordinates. /// - [Fact] + [TestMethod] public void FilterChange_RebalancesVisibleGraph() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var graph = DependencyGraphBuilder.Build(a); var disambig = EmptyDisambig(); var all = LayoutFor(graph, DependencyGraphScope.All, hideFramework: false, w: 200, h: 80); var direct = LayoutFor(graph, DependencyGraphScope.DirectOnly, hideFramework: false, w: 200, h: 80); - Assert.True(direct.Nodes.Count < all.Nodes.Count, - "DirectOnly should drop transitive nodes"); - Assert.All(direct.Nodes, rn => Assert.True(rn.VisibleDepth <= 1)); + Assert.IsLessThan(all.Nodes.Count, direct.Nodes.Count, "DirectOnly should drop transitive nodes"); + TestAssert.All(direct.Nodes, rn => Assert.IsLessThanOrEqualTo(1, rn.VisibleDepth)); // Rebalance: the direct layout is vertically shorter because it loses the deeper // bands entirely. If the view reused full-graph geometry, the direct layout's max Y @@ -38,23 +39,22 @@ public void FilterChange_RebalancesVisibleGraph() // layout was recomputed from the visible subgraph, not sliced from stale positions. var allMaxY = all.Nodes.Max(rn => rn.Y); var directMaxY = direct.Nodes.Max(rn => rn.Y); - Assert.True(directMaxY < allMaxY, - $"DirectOnly should be shorter; got direct max Y = {directMaxY} vs all = {allMaxY}"); + Assert.IsLessThan(allMaxY, directMaxY, $"DirectOnly should be shorter; got direct max Y = {directMaxY} vs all = {allMaxY}"); } /// /// Under DirectOnly there are at most two visible-depth bands: root (depth 0) and /// the direct refs (depth 1). No node lives deeper. /// - [Fact] + [TestMethod] public void DirectOnly_UsesOnlyVisibleDepthBands() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var graph = DependencyGraphBuilder.Build(a); var direct = LayoutFor(graph, DependencyGraphScope.DirectOnly, hideFramework: false, w: 180, h: 60); - Assert.All(direct.Nodes, rn => Assert.InRange(rn.VisibleDepth, 0, 1)); - Assert.Single(direct.Nodes, rn => rn.VisibleDepth == 0); + TestAssert.All(direct.Nodes, rn => Assert.IsInRange(0, 1, rn.VisibleDepth)); + Assert.ContainsSingle(rn => rn.VisibleDepth == 0, direct.Nodes); } /// @@ -64,7 +64,7 @@ public void DirectOnly_UsesOnlyVisibleDepthBands() /// no further refs exercises the pruning path; with hideFramework on, the leaf /// vanishes and nothing else remains at depth > 0. /// - [Fact] + [TestMethod] public void FrameworkFilter_PrunesDisconnectedVisibleIslands() { using var scope = SyntheticAssemblyScope.Create(); @@ -80,16 +80,16 @@ public void FrameworkFilter_PrunesDisconnectedVisibleIslands() graph.Nodes, graph.Edges, graph.NavigationById, DependencyGraphScope.All, hideFramework: true); - Assert.Single(visible.Nodes, n => n.IsRoot); - Assert.DoesNotContain(visible.Nodes, n => !n.IsRoot); - Assert.Empty(visible.Edges); + Assert.ContainsSingle(n => n.IsRoot, visible.Nodes); + Assert.DoesNotContain(n => !n.IsRoot, visible.Nodes); + Assert.IsEmpty(visible.Edges); } /// /// Doubling the viewport width reflows the layout and produces a valid, non-overlapping /// arrangement. No two rendered boxes share any character cell in the larger layout. /// - [Fact] + [TestMethod] public void ViewportResize_ReflowsWithoutBoxOverlap() { using var scope = SyntheticAssemblyScope.Create(); @@ -110,7 +110,7 @@ public void ViewportResize_ReflowsWithoutBoxOverlap() AssertNoOverlap(wide); AssertNoOverlap(narrow); - Assert.NotEqual>( + Assert.AreNotSequenceEqual( [.. narrow.Nodes.Select(rn => (rn.X, rn.Y))], [.. wide.Nodes.Select(rn => (rn.X, rn.Y))]); } @@ -120,7 +120,7 @@ [.. narrow.Nodes.Select(rn => (rn.X, rn.Y))], /// returns exactly one winner — the closest box center to the mouse, with ties broken by /// later draw order. /// - [Fact] + [TestMethod] public void Hover_OverlappingBoxes_SelectsSingleWinner() { // first box: X=10..30, center 20. second box: X=15..35, center 25. @@ -131,15 +131,15 @@ public void Hover_OverlappingBoxes_SelectsSingleWinner() // Column 24 is inside both boxes. Distance to first center (20) = 4; to second // center (25) = 1. Second wins on closer-to-center. var winner = DependencyGraphView.ResolveHoverWinner(nodes, mouseX: 24, mouseY: 6); - Assert.Equal(1, winner); + Assert.AreEqual(1, winner); // Column 12 is inside only the first box. winner = DependencyGraphView.ResolveHoverWinner(nodes, mouseX: 12, mouseY: 6); - Assert.Equal(0, winner); + Assert.AreEqual(0, winner); // Outside both. winner = DependencyGraphView.ResolveHoverWinner(nodes, mouseX: 0, mouseY: 0); - Assert.Equal(-1, winner); + Assert.AreEqual(-1, winner); } /// @@ -148,7 +148,7 @@ public void Hover_OverlappingBoxes_SelectsSingleWinner() /// view writes to GraphSelectedNode. Validates the single-source-of-truth contract /// for hover. /// - [Fact] + [TestMethod] public void Hover_WinnerMatchesStatusBarAndHighlight() { // left box: X=0..12, center 6. right box: X=6..18, center 12. @@ -160,15 +160,15 @@ public void Hover_WinnerMatchesStatusBarAndHighlight() // Right wins on closer-to-center — and that is also what the status bar will show. var winner = DependencyGraphView.ResolveHoverWinner(nodes, mouseX: 11, mouseY: 1); - Assert.Equal(1, winner); - Assert.Equal("overlap-right", nodes[winner].Node.Name); + Assert.AreEqual(1, winner); + Assert.AreEqual("overlap-right", nodes[winner].Node.Name); } /// /// Non-winning overlapping boxes do not end up treated as hovered. The winner is unique, /// and any other boxes that also contained the mouse point are left at their base colors. /// - [Fact] + [TestMethod] public void Hover_NonWinningOverlapsStayUnhighlighted() { var nodes = MakeRenderNodes( @@ -179,12 +179,12 @@ public void Hover_NonWinningOverlapsStayUnhighlighted() // is at column 10. Closer to winning => winning wins, losing stays in neither // hovered nor selected sets. var winner = DependencyGraphView.ResolveHoverWinner(nodes, mouseX: 14, mouseY: 1); - Assert.Equal(1, winner); + Assert.AreEqual(1, winner); // Sanity: the losing box still contains the mouse point, but is not the winner. var losing = nodes[0]; - Assert.True(14 >= losing.X && 14 < losing.X + losing.Width); - Assert.NotEqual(0, winner); + Assert.IsTrue(14 >= losing.X && 14 < losing.X + losing.Width); + Assert.AreNotEqual(0, winner); } /// @@ -193,13 +193,13 @@ public void Hover_NonWinningOverlapsStayUnhighlighted() /// the viewport height would be stranded off-screen; with scroll, the view slides until /// any node's box lies entirely inside the viewport band. /// - [Fact] + [TestMethod] public void ContentExceedingViewport_RemainsReachableViaScroll() { var graph = BuildManyDirectRefsGraph(count: 80); var layout = LayoutFor(graph, DependencyGraphScope.All, hideFramework: false, w: 80, h: 20); - Assert.True(layout.ContentHeight > 20, "expected content to exceed viewport"); + Assert.IsGreaterThan(20, layout.ContentHeight, "expected content to exceed viewport"); // For every node, there exists a scroll offset in [0, contentHeight - viewport] that // lands the node's full box inside the viewport. @@ -209,8 +209,7 @@ public void ContentExceedingViewport_RemainsReachableViaScroll() needed = Math.Min(needed, layout.ContentHeight - 20); var visibleTop = rn.Y - needed; var visibleBottom = visibleTop + rn.Height; - Assert.True(visibleTop >= 0 && visibleBottom <= 20, - $"{rn.Node.Name}: no scroll offset reveals the full box"); + Assert.IsTrue(visibleTop >= 0 && visibleBottom <= 20, $"{rn.Node.Name}: no scroll offset reveals the full box"); } } @@ -220,10 +219,10 @@ public void ContentExceedingViewport_RemainsReachableViaScroll() /// which looks broken. Two adjacent bands should stay within a handful of rows of each /// other regardless of viewport height. /// - [Fact] + [TestMethod] public void AdjacentDepthBands_SeparatedByFixedAestheticGap() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var graph = DependencyGraphBuilder.Build(a); var narrow = LayoutFor(graph, DependencyGraphScope.DirectOnly, hideFramework: true, w: 200, h: 20); @@ -233,8 +232,8 @@ public void AdjacentDepthBands_SeparatedByFixedAestheticGap() var tallGap = GapBetweenRootAndFirstDirect(tall); // Gap is small and does not scale with viewport height. - Assert.InRange(tallGap, 1, 8); - Assert.Equal(narrowGap, tallGap); + Assert.IsInRange(1, 8, tallGap); + Assert.AreEqual(narrowGap, tallGap); } private static int GapBetweenRootAndFirstDirect(GraphRenderLayout layout) @@ -249,14 +248,14 @@ private static int GapBetweenRootAndFirstDirect(GraphRenderLayout layout) /// plus the distributed inter-band padding — it reflects the actual layout footprint /// so scroll-range and fits-vs-scrolls decisions can be made from one authoritative value. /// - [Fact] + [TestMethod] public void ContentHeight_MatchesRenderedExtent() { var graph = BuildManyDirectRefsGraph(count: 80); var layout = LayoutFor(graph, DependencyGraphScope.All, hideFramework: false, w: 80, h: 20); var bottom = layout.Nodes.Max(rn => rn.Y + rn.Height); - Assert.Equal(bottom, layout.ContentHeight); + Assert.AreEqual(bottom, layout.ContentHeight); } private static DependencyGraphResult BuildManyDirectRefsGraph(int count) @@ -297,7 +296,7 @@ private static void AssertNoOverlap(GraphRenderLayout layout) var b = layout.Nodes[j]; var horizOverlap = a.X < b.X + b.Width && b.X < a.X + a.Width; var vertOverlap = a.Y < b.Y + b.Height && b.Y < a.Y + a.Height; - Assert.False(horizOverlap && vertOverlap, + Assert.IsFalse(horizOverlap && vertOverlap, $"boxes overlap: {a.Node.Name} at ({a.X},{a.Y},{a.Width},{a.Height}) " + $"and {b.Node.Name} at ({b.X},{b.Y},{b.Width},{b.Height})"); } diff --git a/tests/Dotsider.Tests/DependencyGraphScopeTests.cs b/tests/Dotsider.Tests/DependencyGraphScopeTests.cs index 79545ccd..0674a9ed 100644 --- a/tests/Dotsider.Tests/DependencyGraphScopeTests.cs +++ b/tests/Dotsider.Tests/DependencyGraphScopeTests.cs @@ -9,47 +9,49 @@ namespace Dotsider.Tests; /// all operate on the same view. Transitive-only is intentionally not offered: hiding direct /// parents would produce disconnected islands. /// -[Collection("SampleAssemblies")] -public sealed class DependencyGraphScopeTests(SampleAssemblyFixture samples) +[TestClass] +public sealed class DependencyGraphScopeTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// keeps only the root and depth-1 nodes /// plus the edges between them; deeper transitive refs drop out. /// - [Fact] + [TestMethod] public void DirectOnly_KeepsOnlyRootAndDepthOne() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var graph = DependencyGraphBuilder.Build(a); - Assert.Contains(graph.Nodes, n => n.Depth > 1); + Assert.Contains(n => n.Depth > 1, graph.Nodes); var visible = DependencyGraphView.BuildVisibleModel( graph.Nodes, graph.Edges, graph.NavigationById, DependencyGraphScope.DirectOnly, hideFramework: false); - Assert.All(visible.Nodes, n => Assert.True(n.IsRoot || n.Depth == 1)); + TestAssert.All(visible.Nodes, n => Assert.IsTrue(n.IsRoot || n.Depth == 1)); var rootId = visible.Nodes.First(n => n.IsRoot).Id; - Assert.All(visible.Edges, e => Assert.Equal(rootId, e.SourceId)); - Assert.All(visible.Edges, e => Assert.Contains(visible.Nodes, n => n.Id == e.TargetId)); + TestAssert.All(visible.Edges, e => Assert.AreEqual(rootId, e.SourceId)); + TestAssert.All(visible.Edges, e => Assert.Contains(n => n.Id == e.TargetId, visible.Nodes)); } /// /// is the default and returns the full closure /// unchanged — anything the builder produced is visible, edges included. /// - [Fact] + [TestMethod] public void All_ReturnsFullClosure() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var graph = DependencyGraphBuilder.Build(a); var visible = DependencyGraphView.BuildVisibleModel( graph.Nodes, graph.Edges, graph.NavigationById, DependencyGraphScope.All, hideFramework: false); - Assert.Equal(graph.Nodes.Count, visible.Nodes.Count); - Assert.Equal(graph.Edges.Count, visible.Edges.Count); + Assert.HasCount(graph.Nodes.Count, visible.Nodes); + Assert.HasCount(graph.Edges.Count, visible.Edges); } /// @@ -57,23 +59,23 @@ public void All_ReturnsFullClosure() /// and the non-framework direct references. For RichLibrary that collapses to root plus /// Newtonsoft.Json. /// - [Fact] + [TestMethod] public void DirectOnly_ComposesWithFrameworkFilter() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var graph = DependencyGraphBuilder.Build(a); var visible = DependencyGraphView.BuildVisibleModel( graph.Nodes, graph.Edges, graph.NavigationById, DependencyGraphScope.DirectOnly, hideFramework: true); - Assert.Contains(visible.Nodes, n => n.IsRoot); - Assert.All(visible.Nodes, n => Assert.True(n.IsRoot || n.Depth == 1)); - Assert.All(visible.Nodes, n => + Assert.Contains(n => n.IsRoot, visible.Nodes); + TestAssert.All(visible.Nodes, n => Assert.IsTrue(n.IsRoot || n.Depth == 1)); + TestAssert.All(visible.Nodes, n => { if (n.IsRoot) return; var ctx = graph.NavigationById[n.Id]; - Assert.False(ctx.IsFrameworkAssembly); + Assert.IsFalse(ctx.IsFrameworkAssembly); }); } @@ -81,20 +83,20 @@ public void DirectOnly_ComposesWithFrameworkFilter() /// Root stays visible under every combination of scope and framework filter, so the /// anchor of the graph is never lost. /// - [Theory] - [InlineData(DependencyGraphScope.All, false)] - [InlineData(DependencyGraphScope.All, true)] - [InlineData(DependencyGraphScope.DirectOnly, false)] - [InlineData(DependencyGraphScope.DirectOnly, true)] + [TestMethod] + [DataRow(DependencyGraphScope.All, false)] + [DataRow(DependencyGraphScope.All, true)] + [DataRow(DependencyGraphScope.DirectOnly, false)] + [DataRow(DependencyGraphScope.DirectOnly, true)] public void Root_IsAlwaysVisible(DependencyGraphScope scope, bool hideFramework) { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var graph = DependencyGraphBuilder.Build(a); var visible = DependencyGraphView.BuildVisibleModel( graph.Nodes, graph.Edges, graph.NavigationById, scope, hideFramework); - Assert.Contains(visible.Nodes, n => n.IsRoot); + Assert.Contains(n => n.IsRoot, visible.Nodes); } /// @@ -102,18 +104,18 @@ public void Root_IsAlwaysVisible(DependencyGraphScope scope, bool hideFramework) /// so index-based consumers (search, selection, yank) cannot accidentally reach into /// hidden nodes. /// - [Fact] + [TestMethod] public void VisibleIndex_MapsOnlyVisibleNodes() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var graph = DependencyGraphBuilder.Build(a); var visible = DependencyGraphView.BuildVisibleModel( graph.Nodes, graph.Edges, graph.NavigationById, DependencyGraphScope.DirectOnly, hideFramework: false); - Assert.Equal(visible.Nodes.Count, visible.IndexById.Count); + Assert.HasCount(visible.Nodes.Count, visible.IndexById); foreach (var n in visible.Nodes) - Assert.Equal(n.Id, visible.Nodes[visible.IndexById[n.Id]].Id); + Assert.AreEqual(n.Id, visible.Nodes[visible.IndexById[n.Id]].Id); } } diff --git a/tests/Dotsider.Tests/DgmlReaderTests.cs b/tests/Dotsider.Tests/DgmlReaderTests.cs index 9a99f70e..72c52d46 100644 --- a/tests/Dotsider.Tests/DgmlReaderTests.cs +++ b/tests/Dotsider.Tests/DgmlReaderTests.cs @@ -7,25 +7,28 @@ namespace Dotsider.Tests; /// Tests for against the real dependency graph published next to the /// NativeAOT sample, plus synthetic and malformed documents. /// -[Collection("SampleAssemblies")] -public class DgmlReaderTests(SampleAssemblyFixture samples) +[TestClass] +public class DgmlReaderTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// Verifies the fixture's codegen graph parses with consistent nodes and links: every /// indexed link endpoint resolves to a node. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_FixtureDgml_ParsesNodesAndLinks() { - Assert.SkipWhen(samples.NativeAotConsoleDgml is null, "DGML sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleDgml is null, "DGML sidecar was not produced"); - var graph = DgmlReader.Read(samples.NativeAotConsoleDgml!); + var graph = DgmlReader.Read(Samples.NativeAotConsoleDgml!); - Assert.NotNull(graph); - Assert.NotEmpty(graph.Nodes); - Assert.NotEmpty(graph.Links); + Assert.IsNotNull(graph); + Assert.IsNotEmpty(graph.Nodes); + Assert.IsNotEmpty(graph.Links); var ids = graph.Nodes.Select(n => n.Id).ToHashSet(); - Assert.All(graph.Links, l => + TestAssert.All(graph.Links, l => { Assert.Contains(l.SourceId, ids); Assert.Contains(l.TargetId, ids); @@ -36,51 +39,54 @@ public void Read_FixtureDgml_ParsesNodesAndLinks() /// Verifies a chain from a real method node reaches a root: the walk ends at a node with /// no dependers, starts the returned list with it, and ends the list at the queried node. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PathToRoot_KnownMethodLabel_ReachesRoot() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - Assert.SkipWhen(samples.NativeAotConsoleDgml is null, "DGML sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleDgml is null, "DGML sidecar was not produced"); - var graph = DgmlReader.Read(samples.NativeAotConsoleDgml!); - var data = MstatReader.Read(samples.NativeAotConsoleMstat!); - Assert.NotNull(graph); - Assert.NotNull(data); + var graph = DgmlReader.Read(Samples.NativeAotConsoleDgml!); + var data = MstatReader.Read(Samples.NativeAotConsoleMstat!); + Assert.IsNotNull(graph); + Assert.IsNotNull(data); // Any compiled method that is present in the graph will do; take the first join hit. var nodeName = data.Methods .Select(m => m.NodeName) .FirstOrDefault(n => n is not null && graph.FindNodeByLabel(n) is not null); - Assert.NotNull(nodeName); + Assert.IsNotNull(nodeName); var path = graph.PathToRoot(nodeName!); - Assert.NotEmpty(path); - Assert.Equal(nodeName, path[^1].Label); - Assert.Null(path[0].Reason); + Assert.IsNotEmpty(path); + Assert.AreEqual(nodeName, path[^1].Label); + Assert.IsNull(path[0].Reason); var rootNode = graph.FindNodeByLabel(path[0].Label); - Assert.NotNull(rootNode); - Assert.DoesNotContain(graph.Links, l => l.TargetId == rootNode.Id); + Assert.IsNotNull(rootNode); + Assert.DoesNotContain(l => l.TargetId == rootNode.Id, graph.Links); } /// /// Verifies an unknown label yields an empty chain rather than throwing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PathToRoot_UnknownLabel_ReturnsEmpty() { - Assert.SkipWhen(samples.NativeAotConsoleDgml is null, "DGML sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleDgml is null, "DGML sidecar was not produced"); - var graph = DgmlReader.Read(samples.NativeAotConsoleDgml!); + var graph = DgmlReader.Read(Samples.NativeAotConsoleDgml!); - Assert.NotNull(graph); - Assert.Empty(graph.PathToRoot("no-such-node-anywhere")); + Assert.IsNotNull(graph); + Assert.IsEmpty(graph.PathToRoot("no-such-node-anywhere")); } /// /// Verifies querying a root returns the single-step chain: the root explains itself. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PathToRoot_RootNode_ReturnsSingleStep() { var graph = DgmlReader.Read(ToStream(""" @@ -95,16 +101,17 @@ public void PathToRoot_RootNode_ReturnsSingleStep() """)); - Assert.NotNull(graph); + Assert.IsNotNull(graph); var path = graph.PathToRoot("root"); - Assert.Single(path); - Assert.Equal("root", path[0].Label); + Assert.ContainsSingle(path); + Assert.AreEqual("root", path[0].Label); } /// /// Verifies a multi-step synthetic chain reconstructs root-first with per-step reasons. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PathToRoot_SyntheticChain_ReturnsRootFirstWithReasons() { var graph = DgmlReader.Read(ToStream(""" @@ -121,19 +128,20 @@ public void PathToRoot_SyntheticChain_ReturnsRootFirstWithReasons() """)); - Assert.NotNull(graph); + Assert.IsNotNull(graph); var path = graph.PathToRoot("Leaf"); - Assert.Equal(3, path.Count); - Assert.Equal(("Main method", (string?)null), (path[0].Label, path[0].Reason)); - Assert.Equal(("Helper", "call"), (path[1].Label, path[1].Reason)); - Assert.Equal(("Leaf", "field access"), (path[2].Label, path[2].Reason)); + Assert.HasCount(3, path); + Assert.AreEqual(("Main method", (string?)null), (path[0].Label, path[0].Reason)); + Assert.AreEqual(("Helper", "call"), (path[1].Label, path[1].Reason)); + Assert.AreEqual(("Leaf", "field access"), (path[2].Label, path[2].Reason)); } /// /// Verifies a cycle with no root reports the queried node alone instead of spinning. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PathToRoot_PureCycle_ReturnsQueriedNodeAlone() { var graph = DgmlReader.Read(ToStream(""" @@ -149,17 +157,18 @@ public void PathToRoot_PureCycle_ReturnsQueriedNodeAlone() """)); - Assert.NotNull(graph); + Assert.IsNotNull(graph); var path = graph.PathToRoot("a"); - Assert.Single(path); - Assert.Equal("a", path[0].Label); + Assert.ContainsSingle(path); + Assert.AreEqual("a", path[0].Label); } /// /// Verifies a document without the dgml namespace still parses — matching is by local /// element name. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_NoNamespace_StillParses() { var graph = DgmlReader.Read(ToStream(""" @@ -169,15 +178,16 @@ public void Read_NoNamespace_StillParses() """)); - Assert.NotNull(graph); - Assert.Single(graph.Nodes); - Assert.Empty(graph.Links); + Assert.IsNotNull(graph); + Assert.ContainsSingle(graph.Nodes); + Assert.IsEmpty(graph.Links); } /// /// Verifies duplicate labels resolve to the first node without throwing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_DuplicateLabels_FirstWins() { var graph = DgmlReader.Read(ToStream(""" @@ -189,14 +199,17 @@ public void Read_DuplicateLabels_FirstWins() """)); - Assert.NotNull(graph); - Assert.Equal(1, graph.FindNodeByLabel("dup")?.Id); + Assert.IsNotNull(graph); + var node = graph.FindNodeByLabel("dup"); + Assert.IsNotNull(node); + Assert.AreEqual(1, node.Id); } /// /// Verifies malformed nodes and links are skipped while well-formed siblings survive. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_MalformedEntries_AreSkipped() { var graph = DgmlReader.Read(ToStream(""" @@ -212,45 +225,49 @@ public void Read_MalformedEntries_AreSkipped() """)); - Assert.NotNull(graph); - Assert.Single(graph.Nodes); - Assert.Single(graph.Links); + Assert.IsNotNull(graph); + Assert.ContainsSingle(graph.Nodes); + Assert.ContainsSingle(graph.Links); } /// /// Verifies a truncated document returns null rather than throwing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_TruncatedXml_ReturnsNull() { - Assert.Null(DgmlReader.Read(ToStream(" /// Verifies a document with the wrong root element returns null. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_WrongRootElement_ReturnsNull() { - Assert.Null(DgmlReader.Read(ToStream(""))); + Assert.IsNull(DgmlReader.Read(ToStream(""))); } /// /// Verifies an empty file returns null rather than throwing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_EmptyFile_ReturnsNull() { - Assert.Null(DgmlReader.Read(ToStream(""))); + Assert.IsNull(DgmlReader.Read(ToStream(""))); } /// /// Verifies a missing file returns null rather than throwing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_MissingFile_ReturnsNull() { - Assert.Null(DgmlReader.Read(Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.dgml.xml"))); + Assert.IsNull(DgmlReader.Read(Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.dgml.xml"))); } private static MemoryStream ToStream(string xml) => new(Encoding.UTF8.GetBytes(xml)); diff --git a/tests/Dotsider.Tests/DiffDecorationProviderTests.cs b/tests/Dotsider.Tests/DiffDecorationProviderTests.cs index 0b8ba099..34bad245 100644 --- a/tests/Dotsider.Tests/DiffDecorationProviderTests.cs +++ b/tests/Dotsider.Tests/DiffDecorationProviderTests.cs @@ -7,6 +7,7 @@ namespace Dotsider.Tests; /// /// Tests for Diff Decoration Provider. /// +[TestClass] public class DiffDecorationProviderTests { // --- DiffSearchDecorationProvider --- @@ -14,7 +15,7 @@ public class DiffDecorationProviderTests /// /// Verifies diff search highlights all matches case insensitive. /// - [Fact] + [TestMethod] public void DiffSearch_HighlightsAllMatches_CaseInsensitive() { var provider = new DiffSearchDecorationProvider { Query = "rich" }; @@ -23,15 +24,15 @@ public void DiffSearch_HighlightsAllMatches_CaseInsensitive() var spans = provider.GetDecorations(1, 3, doc); // "Rich" on line 1, "rich" on line 3 - Assert.Equal(2, spans.Count); - Assert.Equal(1, spans[0].Start.Line); - Assert.Equal(3, spans[1].Start.Line); + Assert.HasCount(2, spans); + Assert.AreEqual(1, spans[0].Start.Line); + Assert.AreEqual(3, spans[1].Start.Line); } /// /// Verifies diff search matches own foreground and background colors. /// - [Fact] + [TestMethod] public void DiffSearch_MatchesOwnForegroundAndBackground() { var provider = new DiffSearchDecorationProvider { Query = "rich" }; @@ -39,9 +40,9 @@ public void DiffSearch_MatchesOwnForegroundAndBackground() var spans = provider.GetDecorations(1, 1, doc); - var span = Assert.Single(spans); - Assert.NotNull(span.Decoration.Foreground); - Assert.NotNull(span.Decoration.Background); + var span = Assert.ContainsSingle(spans); + Assert.IsNotNull(span.Decoration.Foreground); + Assert.IsNotNull(span.Decoration.Background); AssertColorEquals(HighlightHelper.MatchFgColor, span.Decoration.Foreground.Value); AssertColorEquals(HighlightHelper.MatchBgColor, span.Decoration.Background.Value); } @@ -49,7 +50,7 @@ public void DiffSearch_MatchesOwnForegroundAndBackground() /// /// Verifies diff search multiple matches on same line. /// - [Fact] + [TestMethod] public void DiffSearch_MultipleMatchesOnSameLine() { var provider = new DiffSearchDecorationProvider { Query = "ab" }; @@ -57,43 +58,43 @@ public void DiffSearch_MultipleMatchesOnSameLine() var spans = provider.GetDecorations(1, 1, doc); - Assert.Equal(3, spans.Count); + Assert.HasCount(3, spans); } /// /// Verifies diff search null query returns empty. /// - [Fact] + [TestMethod] public void DiffSearch_NullQuery_ReturnsEmpty() { var provider = new DiffSearchDecorationProvider { Query = null }; var doc = new Hex1bDocument("some text"); - Assert.Empty(provider.GetDecorations(1, 1, doc)); + Assert.IsEmpty(provider.GetDecorations(1, 1, doc)); } /// /// Verifies diff search empty query returns empty. /// - [Fact] + [TestMethod] public void DiffSearch_EmptyQuery_ReturnsEmpty() { var provider = new DiffSearchDecorationProvider { Query = "" }; var doc = new Hex1bDocument("some text"); - Assert.Empty(provider.GetDecorations(1, 1, doc)); + Assert.IsEmpty(provider.GetDecorations(1, 1, doc)); } /// /// Verifies diff search no match returns empty. /// - [Fact] + [TestMethod] public void DiffSearch_NoMatch_ReturnsEmpty() { var provider = new DiffSearchDecorationProvider { Query = "xyz" }; var doc = new Hex1bDocument("abc def"); - Assert.Empty(provider.GetDecorations(1, 1, doc)); + Assert.IsEmpty(provider.GetDecorations(1, 1, doc)); } // --- DiffStatsDecorationProvider --- @@ -101,7 +102,7 @@ public void DiffSearch_NoMatch_ReturnsEmpty() /// /// Verifies diff stats colors stats lines. /// - [Fact] + [TestMethod] public void DiffStats_ColorsStatsLines() { var provider = new DiffStatsDecorationProvider(); @@ -110,13 +111,13 @@ public void DiffStats_ColorsStatsLines() var spans = provider.GetDecorations(1, 2, doc); // Each line has 3 colored values (+N, -N, ~N) = 6 total - Assert.Equal(6, spans.Count); + Assert.HasCount(6, spans); } /// /// Verifies diff stats does not color size delta line. /// - [Fact] + [TestMethod] public void DiffStats_DoesNotColorSizeDeltaLine() { var provider = new DiffStatsDecorationProvider(); @@ -125,13 +126,13 @@ public void DiffStats_DoesNotColorSizeDeltaLine() var spans = provider.GetDecorations(1, 1, doc); // Line has no ~ so it's skipped entirely - Assert.Empty(spans); + Assert.IsEmpty(spans); } /// /// Verifies diff stats ignores lines without tilde. /// - [Fact] + [TestMethod] public void DiffStats_IgnoresLinesWithoutTilde() { var provider = new DiffStatsDecorationProvider(); @@ -139,13 +140,13 @@ public void DiffStats_IgnoresLinesWithoutTilde() var spans = provider.GetDecorations(1, 1, doc); - Assert.Empty(spans); + Assert.IsEmpty(spans); } /// /// Verifies diff stats requires digit after prefix. /// - [Fact] + [TestMethod] public void DiffStats_RequiresDigitAfterPrefix() { var provider = new DiffStatsDecorationProvider(); @@ -154,25 +155,25 @@ public void DiffStats_RequiresDigitAfterPrefix() var spans = provider.GetDecorations(1, 1, doc); - Assert.Single(spans); + Assert.ContainsSingle(spans); } /// /// Verifies diff stats empty lines returns empty. /// - [Fact] + [TestMethod] public void DiffStats_EmptyLines_ReturnsEmpty() { var provider = new DiffStatsDecorationProvider(); var doc = new Hex1bDocument("\n\n"); - Assert.Empty(provider.GetDecorations(1, 3, doc)); + Assert.IsEmpty(provider.GetDecorations(1, 3, doc)); } private static void AssertColorEquals(Hex1bColor expected, Hex1bColor actual) { - Assert.Equal(expected.R, actual.R); - Assert.Equal(expected.G, actual.G); - Assert.Equal(expected.B, actual.B); + Assert.AreEqual(expected.R, actual.R); + Assert.AreEqual(expected.G, actual.G); + Assert.AreEqual(expected.B, actual.B); } } diff --git a/tests/Dotsider.Tests/DiffModeViewTests.cs b/tests/Dotsider.Tests/DiffModeViewTests.cs index e9e1a82c..52d96048 100644 --- a/tests/Dotsider.Tests/DiffModeViewTests.cs +++ b/tests/Dotsider.Tests/DiffModeViewTests.cs @@ -8,9 +8,11 @@ namespace Dotsider.Tests; /// /// Tests for Diff Mode View. /// -[Collection("SampleAssemblies")] -public class DiffModeViewTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class DiffModeViewTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _hex1bApp; @@ -28,7 +30,7 @@ public class DiffModeViewTests(SampleAssemblyFixture samples) : IDisposable _hex1bApp = new Hex1bApp( ctx => { - _state ??= new DiffState(_hex1bApp!, samples.RichLibraryDll, samples.RichLibraryV2Dll); + _state ??= new DiffState(_hex1bApp!, Samples.RichLibraryDll, Samples.RichLibraryV2Dll); diffApp ??= new DiffApp(_state); return Task.FromResult(diffApp.Build(ctx)); }, @@ -43,11 +45,12 @@ public class DiffModeViewTests(SampleAssemblyFixture samples) : IDisposable /// /// Verifies diff app launches shows both assemblies. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task DiffApp_Launches_ShowsBothAssemblies() { var (terminal, app) = CreateDiffApp(); - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); @@ -67,11 +70,12 @@ public async Task DiffApp_Launches_ShowsBothAssemblies() /// /// Verifies diff app shows diff entries. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task DiffApp_ShowsDiffEntries() { var (terminal, app) = CreateDiffApp(); - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); @@ -91,11 +95,12 @@ public async Task DiffApp_ShowsDiffEntries() /// /// Verifies quit key exits diff app. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task QuitKey_ExitsDiffApp() { var (terminal, app) = CreateDiffApp(); - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); @@ -107,17 +112,18 @@ public async Task QuitKey_ExitsDiffApp() .ApplyAsync(terminal, ct); var completed = await Task.WhenAny(runTask, Task.Delay(5000, ct)); - Assert.Equal(runTask, completed); + Assert.AreEqual(runTask, completed); } /// /// Verifies arrow keys cycle tabs. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ArrowKeys_CycleTabs() { var (terminal, app) = CreateDiffApp(); - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); @@ -137,7 +143,7 @@ public async Task ArrowKeys_CycleTabs() .Build() .ApplyAsync(terminal, ct); - Assert.Equal(1, _state!.CurrentTab); + Assert.AreEqual(1, _state!.CurrentTab); await runTask.ContinueWith(_ => { }, ct); } @@ -145,11 +151,12 @@ public async Task ArrowKeys_CycleTabs() /// /// Verifies search activates and filters. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Search_ActivatesAndFilters() { var (terminal, app) = CreateDiffApp(); - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); @@ -165,7 +172,7 @@ public async Task Search_ActivatesAndFilters() .Build() .ApplyAsync(terminal, ct); - Assert.True(_state!.Search[1].IsActive); + Assert.IsTrue(_state!.Search[1].IsActive); await runTask.ContinueWith(_ => { }, ct); } @@ -173,11 +180,12 @@ public async Task Search_ActivatesAndFilters() /// /// Verifies diff app shows references tab. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task DiffApp_ShowsReferencesTab() { var (terminal, app) = CreateDiffApp(); - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); @@ -194,7 +202,7 @@ public async Task DiffApp_ShowsReferencesTab() .Build() .ApplyAsync(terminal, ct); - Assert.Equal(3, _state!.CurrentTab); + Assert.AreEqual(3, _state!.CurrentTab); await runTask.ContinueWith(_ => { }, ct); } @@ -202,11 +210,12 @@ public async Task DiffApp_ShowsReferencesTab() /// /// Verifies diff app shows methods tab. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task DiffApp_ShowsMethodsTab() { var (terminal, app) = CreateDiffApp(); - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); @@ -223,7 +232,7 @@ public async Task DiffApp_ShowsMethodsTab() .Build() .ApplyAsync(terminal, ct); - Assert.Equal(2, _state!.CurrentTab); + Assert.AreEqual(2, _state!.CurrentTab); await runTask.ContinueWith(_ => { }, ct); } @@ -231,11 +240,12 @@ public async Task DiffApp_ShowsMethodsTab() /// /// Verifies left arrow does not go below zero. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task LeftArrow_DoesNotGoBelowZero() { var (terminal, app) = CreateDiffApp(); - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); @@ -249,7 +259,7 @@ public async Task LeftArrow_DoesNotGoBelowZero() .Build() .ApplyAsync(terminal, ct); - Assert.Equal(0, _state!.CurrentTab); + Assert.AreEqual(0, _state!.CurrentTab); await runTask.ContinueWith(_ => { }, ct); } @@ -259,10 +269,10 @@ public async Task LeftArrow_DoesNotGoBelowZero() /// public void Dispose() { + GC.SuppressFinalize(this); _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/DiffModeYankIntegrationTests.cs b/tests/Dotsider.Tests/DiffModeYankIntegrationTests.cs index 0c6e408b..2c72c0c5 100644 --- a/tests/Dotsider.Tests/DiffModeYankIntegrationTests.cs +++ b/tests/Dotsider.Tests/DiffModeYankIntegrationTests.cs @@ -9,19 +9,22 @@ namespace Dotsider.Tests; /// /// Tests for Diff Mode Yank Integration. /// -[Collection("SampleAssemblies")] -public class DiffModeYankIntegrationTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class DiffModeYankIntegrationTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private ClipboardCapturingWorkloadAdapter? _clipboardAdapter; private Hex1bTerminal? _terminal; private Hex1bApp? _hex1bApp; private DiffState? _state; private CancellationTokenSource? _cts; + private Task? _runTask; private (Hex1bTerminal terminal, Hex1bApp app, CancellationToken ct) Launch() { - _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + _cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); _workload = new Hex1bAppWorkloadAdapter(); _clipboardAdapter = new ClipboardCapturingWorkloadAdapter(_workload); _terminal = Hex1bTerminal.CreateBuilder() @@ -33,7 +36,7 @@ public class DiffModeYankIntegrationTests(SampleAssemblyFixture samples) : IDisp _hex1bApp = new Hex1bApp( ctx => { - _state ??= new DiffState(_hex1bApp!, samples.RichLibraryDll, samples.RichLibraryV2Dll); + _state ??= new DiffState(_hex1bApp!, Samples.RichLibraryDll, Samples.RichLibraryV2Dll); diffApp ??= new DiffApp(_state); return Task.FromResult(diffApp.Build(ctx)); }, @@ -45,16 +48,60 @@ public class DiffModeYankIntegrationTests(SampleAssemblyFixture samples) : IDisp return (_terminal, _hex1bApp, _cts.Token); } + private Task RunAppAsync(Hex1bApp app, CancellationToken ct) + { + _runTask = app.RunAsync(ct); + return _runTask; + } + + private async Task WaitForStableYankPayloadAsync(CancellationToken ct) + { + string? last = null; + var stablePolls = 0; + var timeoutAt = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (DateTime.UtcNow < timeoutAt) + { + ct.ThrowIfCancellationRequested(); + + var current = _state is null ? null : YankHelper.GetYankText(_state); + if (current is not null && current == last) + { + stablePolls++; + if (stablePolls >= 2) + return current; + } + else + { + last = current; + stablePolls = current is null ? 0 : 1; + } + + await Task.Delay(50, ct); + } + + Assert.Fail("Timed out waiting for diff yank payload to settle."); + throw new InvalidOperationException("Unreachable."); + } + + private bool TryWaitForAppExit() + { + if (_runTask is null) return true; + try { return _runTask.Wait(TimeSpan.FromSeconds(5)); } + catch (AggregateException ex) when (ex.InnerExceptions.All(static e => e is OperationCanceledException)) { return true; } + catch (OperationCanceledException) { return true; } + } + // --- Summary tab --- /// /// Verifies summary tab cycles through editors. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Summary_TabCyclesThroughEditors() { var (terminal, app, ct) = Launch(); - var runTask = app.RunAsync(ct); + var runTask = RunAppAsync(app, ct); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.InAlternateScreen, TimeSpan.FromSeconds(10)) @@ -105,11 +152,12 @@ public async Task Summary_TabCyclesThroughEditors() /// /// Verifies summary left right do not switch tabs when editor focused. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Summary_LeftRightDoNotSwitchTabsWhenEditorFocused() { var (terminal, app, ct) = Launch(); - var runTask = app.RunAsync(ct); + var runTask = RunAppAsync(app, ct); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.InAlternateScreen, TimeSpan.FromSeconds(10)) @@ -131,7 +179,7 @@ public async Task Summary_LeftRightDoNotSwitchTabsWhenEditorFocused() .ApplyAsync(terminal, ct); await Task.Delay(100, ct); - Assert.Equal(tabBefore, _state.CurrentTab); + Assert.AreEqual(tabBefore, _state.CurrentTab); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -142,11 +190,12 @@ public async Task Summary_LeftRightDoNotSwitchTabsWhenEditorFocused() /// /// Verifies types yank on focused row shows notification. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Types_YankOnFocusedRow_ShowsNotification() { var (terminal, app, ct) = Launch(); - var runTask = app.RunAsync(ct); + var runTask = RunAppAsync(app, ct); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.InAlternateScreen, TimeSpan.FromSeconds(10)) @@ -157,8 +206,6 @@ public async Task Types_YankOnFocusedRow_ShowsNotification() .Build() .ApplyAsync(terminal, ct); - await Task.Delay(200, ct); - // If DiffFocusedKey is still null, try j to navigate in table if (_state!.DiffFocusedKey is null) { @@ -169,26 +216,23 @@ public async Task Types_YankOnFocusedRow_ShowsNotification() await Task.Delay(200, ct); } - Assert.NotNull(_state.DiffFocusedKey); + Assert.IsNotNull(_state.DiffFocusedKey); - // Compute expected payload before yank - var expectedPayload = YankHelper.GetYankText(_state); - Assert.NotNull(expectedPayload); + // Compute expected payload after the table's focus callback has settled. + var expectedPayload = await WaitForStableYankPayloadAsync(ct); Assert.Contains("\t", expectedPayload); // Tab-separated // Yank await new Hex1bTerminalInputSequenceBuilder() .Type("y") - .WaitUntil(s => s.ContainsText("Yanked:"), TimeSpan.FromSeconds(5)) + .WaitUntil(_ => _state!.YankNotification is not null, TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(_state!.YankNotification); - // Verify the actual OSC 52 clipboard payload emitted by ctx.CopyToClipboard - Assert.True(_clipboardAdapter!.ClipboardWrites.TryDequeue(out var actualClipboard), + Assert.IsTrue(_clipboardAdapter!.ClipboardWrites.TryDequeue(out var actualClipboard), "CopyToClipboard should have emitted an OSC 52 sequence"); - Assert.Equal(expectedPayload, actualClipboard); + Assert.AreEqual(expectedPayload, actualClipboard); // Notification auto-clears await new Hex1bTerminalInputSequenceBuilder() @@ -203,11 +247,12 @@ public async Task Types_YankOnFocusedRow_ShowsNotification() /// /// Verifies types search with navigation cycles matches. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Types_SearchWithNavigation_CyclesMatches() { var (terminal, app, ct) = Launch(); - var runTask = app.RunAsync(ct); + var runTask = RunAppAsync(app, ct); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.InAlternateScreen, TimeSpan.FromSeconds(10)) @@ -251,7 +296,7 @@ await TestHelpers.WaitUntilAsync( TimeSpan.FromSeconds(5)); var secondFocused = _state.DiffFocusedKey; - Assert.NotEqual(firstFocused, secondFocused); + Assert.AreNotEqual(firstFocused, secondFocused); // N → previous match (moves to a different row) await new Hex1bTerminalInputSequenceBuilder() @@ -263,7 +308,7 @@ await TestHelpers.WaitUntilAsync( () => _state!.DiffFocusedKey is not null && !Equals(_state.DiffFocusedKey, secondFocused), TimeSpan.FromSeconds(5)); - Assert.NotEqual(secondFocused, _state.DiffFocusedKey); + Assert.AreNotEqual(secondFocused, _state.DiffFocusedKey); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -272,11 +317,12 @@ await TestHelpers.WaitUntilAsync( /// /// Verifies tab arrows work when editor not focused. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task TabArrows_WorkWhenEditorNotFocused() { var (terminal, app, ct) = Launch(); - var runTask = app.RunAsync(ct); + var runTask = RunAppAsync(app, ct); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.InAlternateScreen, TimeSpan.FromSeconds(10)) @@ -284,7 +330,7 @@ public async Task TabArrows_WorkWhenEditorNotFocused() .Build() .ApplyAsync(terminal, ct); - Assert.Equal(0, _state!.CurrentTab); // Summary + Assert.AreEqual(0, _state!.CurrentTab); // Summary // Right arrow switches to Types tab (tab index 1) await new Hex1bTerminalInputSequenceBuilder() @@ -293,7 +339,7 @@ public async Task TabArrows_WorkWhenEditorNotFocused() .Build() .ApplyAsync(terminal, ct); - Assert.Equal(1, _state.CurrentTab); + Assert.AreEqual(1, _state.CurrentTab); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -304,11 +350,12 @@ public async Task TabArrows_WorkWhenEditorNotFocused() /// /// Verifies summary selection yank shows notification. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Summary_SelectionYank_ShowsNotification() { var (terminal, app, ct) = Launch(); - var runTask = app.RunAsync(ct); + var runTask = RunAppAsync(app, ct); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.InAlternateScreen, TimeSpan.FromSeconds(10)) @@ -336,12 +383,10 @@ public async Task Summary_SelectionYank_ShowsNotification() // Yank await new Hex1bTerminalInputSequenceBuilder() .Type("y") - .WaitUntil(s => s.ContainsText("Yanked:"), TimeSpan.FromSeconds(5)) + .WaitUntil(_ => _state!.YankNotification is not null, TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(_state!.YankNotification); - _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } } @@ -349,11 +394,12 @@ public async Task Summary_SelectionYank_ShowsNotification() /// /// Verifies summary right info yank shows notification. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Summary_RightInfoYank_ShowsNotification() { var (terminal, app, ct) = Launch(); - var runTask = app.RunAsync(ct); + var runTask = RunAppAsync(app, ct); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.InAlternateScreen, TimeSpan.FromSeconds(10)) @@ -376,12 +422,10 @@ public async Task Summary_RightInfoYank_ShowsNotification() .Shift().Key(Hex1bKey.RightArrow) .WaitUntil(_ => _state!.RightInfoEditorState!.Cursor.HasSelection, TimeSpan.FromSeconds(5)) .Type("y") - .WaitUntil(s => s.ContainsText("Yanked:"), TimeSpan.FromSeconds(5)) + .WaitUntil(_ => _state!.YankNotification is not null, TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(_state!.YankNotification); - _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } } @@ -389,11 +433,12 @@ public async Task Summary_RightInfoYank_ShowsNotification() /// /// Verifies summary change stats yank shows notification. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Summary_ChangeStatsYank_ShowsNotification() { var (terminal, app, ct) = Launch(); - var runTask = app.RunAsync(ct); + var runTask = RunAppAsync(app, ct); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.InAlternateScreen, TimeSpan.FromSeconds(10)) @@ -429,12 +474,10 @@ public async Task Summary_ChangeStatsYank_ShowsNotification() .Shift().Key(Hex1bKey.RightArrow) .WaitUntil(_ => _state!.ChangeStatsEditorState!.Cursor.HasSelection, TimeSpan.FromSeconds(5)) .Type("y") - .WaitUntil(s => s.ContainsText("Yanked:"), TimeSpan.FromSeconds(5)) + .WaitUntil(_ => _state!.YankNotification is not null, TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(_state!.YankNotification); - _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } } @@ -444,11 +487,12 @@ public async Task Summary_ChangeStatsYank_ShowsNotification() /// /// Verifies methods yank on focused row shows notification. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Methods_YankOnFocusedRow_ShowsNotification() { var (terminal, app, ct) = Launch(); - var runTask = app.RunAsync(ct); + var runTask = RunAppAsync(app, ct); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.InAlternateScreen, TimeSpan.FromSeconds(10)) @@ -462,12 +506,10 @@ public async Task Methods_YankOnFocusedRow_ShowsNotification() await new Hex1bTerminalInputSequenceBuilder() .Type("y") - .WaitUntil(s => s.ContainsText("Yanked:"), TimeSpan.FromSeconds(5)) + .WaitUntil(_ => _state!.YankNotification is not null, TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(_state!.YankNotification); - _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } } @@ -477,11 +519,12 @@ public async Task Methods_YankOnFocusedRow_ShowsNotification() /// /// Verifies refs yank on focused row shows notification. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Refs_YankOnFocusedRow_ShowsNotification() { var (terminal, app, ct) = Launch(); - var runTask = app.RunAsync(ct); + var runTask = RunAppAsync(app, ct); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.InAlternateScreen, TimeSpan.FromSeconds(10)) @@ -495,12 +538,10 @@ public async Task Refs_YankOnFocusedRow_ShowsNotification() await new Hex1bTerminalInputSequenceBuilder() .Type("y") - .WaitUntil(s => s.ContainsText("Yanked:"), TimeSpan.FromSeconds(5)) + .WaitUntil(_ => _state!.YankNotification is not null, TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(_state!.YankNotification); - _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } } @@ -510,11 +551,12 @@ public async Task Refs_YankOnFocusedRow_ShowsNotification() /// /// Verifies methods search navigation cycles matches. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Methods_SearchNavigation_CyclesMatches() { var (terminal, app, ct) = Launch(); - var runTask = app.RunAsync(ct); + var runTask = RunAppAsync(app, ct); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.InAlternateScreen, TimeSpan.FromSeconds(10)) @@ -535,6 +577,7 @@ public async Task Methods_SearchNavigation_CyclesMatches() var s = _state!.Search[_state.CurrentTab]; return s.IsActive && s.IsConfirmed; }, TimeSpan.FromSeconds(5)) + .WaitUntil(s => s.ContainsText("n/N: navigate"), TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); @@ -542,21 +585,21 @@ public async Task Methods_SearchNavigation_CyclesMatches() await new Hex1bTerminalInputSequenceBuilder() .Type("n") + .WaitUntil(_ => !Equals(_state!.DiffFocusedKey, first), TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); - await Task.Delay(100, ct); var second = _state.DiffFocusedKey; - Assert.NotEqual(first, second); + Assert.AreNotEqual(first, second); // N → previous match (moves to a different row) await new Hex1bTerminalInputSequenceBuilder() .Shift().Key(Hex1bKey.N) + .WaitUntil(_ => !Equals(_state!.DiffFocusedKey, second), TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); - await Task.Delay(100, ct); - Assert.NotEqual(second, _state.DiffFocusedKey); + Assert.AreNotEqual(second, _state.DiffFocusedKey); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -567,11 +610,12 @@ public async Task Methods_SearchNavigation_CyclesMatches() /// /// Verifies refs search navigation cycles matches. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Refs_SearchNavigation_CyclesMatches() { var (terminal, app, ct) = Launch(); - var runTask = app.RunAsync(ct); + var runTask = RunAppAsync(app, ct); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.InAlternateScreen, TimeSpan.FromSeconds(10)) @@ -592,6 +636,7 @@ public async Task Refs_SearchNavigation_CyclesMatches() var s = _state!.Search[_state.CurrentTab]; return s.IsActive && s.IsConfirmed; }, TimeSpan.FromSeconds(5)) + .WaitUntil(s => s.ContainsText("n/N: navigate"), TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); @@ -599,21 +644,21 @@ public async Task Refs_SearchNavigation_CyclesMatches() await new Hex1bTerminalInputSequenceBuilder() .Type("n") + .WaitUntil(_ => !Equals(_state!.DiffFocusedKey, first), TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); - await Task.Delay(100, ct); var second = _state.DiffFocusedKey; - Assert.NotEqual(first, second); + Assert.AreNotEqual(first, second); // N → previous match (moves to a different row) await new Hex1bTerminalInputSequenceBuilder() .Shift().Key(Hex1bKey.N) + .WaitUntil(_ => !Equals(_state!.DiffFocusedKey, second), TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); - await Task.Delay(100, ct); - Assert.NotEqual(second, _state.DiffFocusedKey); + Assert.AreNotEqual(second, _state.DiffFocusedKey); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -624,11 +669,12 @@ public async Task Refs_SearchNavigation_CyclesMatches() /// /// Verifies types yank flash sets and clears. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Types_YankFlash_SetsAndClears() { var (terminal, app, ct) = Launch(); - var runTask = app.RunAsync(ct); + var runTask = RunAppAsync(app, ct); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.InAlternateScreen, TimeSpan.FromSeconds(10)) @@ -652,7 +698,7 @@ public async Task Types_YankFlash_SetsAndClears() .Build() .ApplyAsync(terminal, ct); - Assert.False(_state!.YankFlashRow); + Assert.IsFalse(_state!.YankFlashRow); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -661,11 +707,12 @@ public async Task Types_YankFlash_SetsAndClears() /// /// Verifies summary yy yanks current line. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Summary_YY_YanksCurrentLine() { var (terminal, app, ct) = Launch(); - var runTask = app.RunAsync(ct); + var runTask = RunAppAsync(app, ct); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.InAlternateScreen, TimeSpan.FromSeconds(10)) @@ -688,10 +735,10 @@ public async Task Summary_YY_YanksCurrentLine() .Build() .ApplyAsync(terminal, ct); - Assert.True(_clipboardAdapter!.ClipboardWrites.TryDequeue(out var yankedText), + Assert.IsTrue(_clipboardAdapter!.ClipboardWrites.TryDequeue(out var yankedText), "CopyToClipboard should have emitted an OSC 52 sequence"); - Assert.True(yankedText.Length > 0); + Assert.IsGreaterThan(0, yankedText.Length); Assert.DoesNotContain("\n", yankedText); _cts!.Cancel(); @@ -703,12 +750,18 @@ public async Task Summary_YY_YanksCurrentLine() /// public void Dispose() { + GC.SuppressFinalize(this); _cts?.Cancel(); + if (!TryWaitForAppExit()) + { + _hex1bApp?.Dispose(); + _terminal?.Dispose(); + _ = TryWaitForAppExit(); + } _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); _cts?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/DiffStateTests.cs b/tests/Dotsider.Tests/DiffStateTests.cs index f0dbb256..7ae49a85 100644 --- a/tests/Dotsider.Tests/DiffStateTests.cs +++ b/tests/Dotsider.Tests/DiffStateTests.cs @@ -6,9 +6,11 @@ namespace Dotsider.Tests; /// /// Tests for Diff State. /// -[Collection("SampleAssemblies")] -public class DiffStateTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class DiffStateTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _app; @@ -30,60 +32,65 @@ private Hex1bApp CreateApp() /// /// Verifies construct both analyzers accessible. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Construct_BothAnalyzersAccessible() { var app = CreateApp(); - using var state = new DiffState(app, samples.RichLibraryDll, samples.RichLibraryV2Dll); - Assert.NotNull(state.Left); - Assert.NotNull(state.Right); - Assert.Equal("RichLibrary", state.Left.AssemblyName); - Assert.Equal("RichLibrary", state.Right.AssemblyName); + using var state = new DiffState(app, Samples.RichLibraryDll, Samples.RichLibraryV2Dll); + Assert.IsNotNull(state.Left); + Assert.IsNotNull(state.Right); + Assert.AreEqual("RichLibrary", state.Left.AssemblyName); + Assert.AreEqual("RichLibrary", state.Right.AssemblyName); } /// /// Verifies construct diff result populated. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Construct_DiffResultPopulated() { var app = CreateApp(); - using var state = new DiffState(app, samples.RichLibraryDll, samples.RichLibraryV2Dll); - Assert.NotNull(state.DiffResult); - Assert.NotEmpty(state.DiffResult.TypeDiffs); + using var state = new DiffState(app, Samples.RichLibraryDll, Samples.RichLibraryV2Dll); + Assert.IsNotNull(state.DiffResult); + Assert.IsNotEmpty(state.DiffResult.TypeDiffs); } /// /// Verifies default filter mode is all. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DefaultFilterMode_IsAll() { var app = CreateApp(); - using var state = new DiffState(app, samples.RichLibraryDll, samples.RichLibraryV2Dll); - Assert.Equal(DiffFilterMode.All, state.FilterMode); + using var state = new DiffState(app, Samples.RichLibraryDll, Samples.RichLibraryV2Dll); + Assert.AreEqual(DiffFilterMode.All, state.FilterMode); } /// /// Verifies tab switching works. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TabSwitching_Works() { var app = CreateApp(); - using var state = new DiffState(app, samples.RichLibraryDll, samples.RichLibraryV2Dll); + using var state = new DiffState(app, Samples.RichLibraryDll, Samples.RichLibraryV2Dll); state.CurrentTab = 2; - Assert.Equal(2, state.CurrentTab); + Assert.AreEqual(2, state.CurrentTab); } /// /// Verifies dispose cleans up. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Dispose_CleansUp() { var app = CreateApp(); - var state = new DiffState(app, samples.RichLibraryDll, samples.RichLibraryV2Dll); + var state = new DiffState(app, Samples.RichLibraryDll, Samples.RichLibraryV2Dll); state.Dispose(); state.Dispose(); // idempotent — should not throw } @@ -93,9 +100,9 @@ public void Dispose_CleansUp() /// public void Dispose() { + GC.SuppressFinalize(this); _app?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/DotNetRuntimeLocatorTests.cs b/tests/Dotsider.Tests/DotNetRuntimeLocatorTests.cs index dc942576..1c2557dd 100644 --- a/tests/Dotsider.Tests/DotNetRuntimeLocatorTests.cs +++ b/tests/Dotsider.Tests/DotNetRuntimeLocatorTests.cs @@ -7,6 +7,7 @@ namespace Dotsider.Tests; /// Tests for covering .NET base path discovery /// and shared framework assembly resolution. /// +[TestClass] public sealed class DotNetRuntimeLocatorTests : IDisposable { /// Initializes a new instance and clears the locator cache. @@ -16,80 +17,88 @@ public sealed class DotNetRuntimeLocatorTests : IDisposable public void Dispose() => DotNetRuntimeLocator.ClearCache(); /// Verifies that the .NET base path exists and contains a shared directory. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindDotNetBasePath_ReturnsValidDirectory() { var basePath = DotNetRuntimeLocator.FindDotNetBasePath(); - Assert.NotNull(basePath); - Assert.True(Directory.Exists(basePath)); - Assert.True(Directory.Exists(Path.Combine(basePath, "shared"))); + Assert.IsNotNull(basePath); + Assert.IsTrue(Directory.Exists(basePath)); + Assert.IsTrue(Directory.Exists(Path.Combine(basePath, "shared"))); } /// Verifies that System.Runtime can be found in the shared framework. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindAssemblyInSharedFramework_SystemRuntime_ReturnsPathAndPack() { var result = DotNetRuntimeLocator.FindAssemblyInSharedFramework("System.Runtime", null); - Assert.NotNull(result); - Assert.True(File.Exists(result.Path)); + Assert.IsNotNull(result); + Assert.IsTrue(File.Exists(result.Path)); Assert.EndsWith(".dll", result.Path); - Assert.NotEmpty(result.RuntimePack); + Assert.IsNotEmpty(result.RuntimePack); } /// Verifies that System.Private.CoreLib can be found in the shared framework. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindAssemblyInSharedFramework_SystemPrivateCoreLib_ReturnsPath() { var result = DotNetRuntimeLocator.FindAssemblyInSharedFramework("System.Private.CoreLib", null); - Assert.NotNull(result); - Assert.True(File.Exists(result.Path)); + Assert.IsNotNull(result); + Assert.IsTrue(File.Exists(result.Path)); } /// Verifies that a nonexistent assembly returns null. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindAssemblyInSharedFramework_NonexistentAssembly_ReturnsNull() { var result = DotNetRuntimeLocator.FindAssemblyInSharedFramework("DoesNotExist.FakeAssembly", null); - Assert.Null(result); + Assert.IsNull(result); } /// Verifies that targeting v10.0 returns a path containing "10.". - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindAssemblyInSharedFramework_WithTargetFramework_MatchesVersion() { var result = DotNetRuntimeLocator.FindAssemblyInSharedFramework( "System.Runtime", ".NETCoreApp,Version=v10.0"); - Assert.NotNull(result); + Assert.IsNotNull(result); Assert.Contains("10.", result.Path); } /// Verifies that NETCore.App preferred pack is probed first. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindAssemblyInSharedFramework_PreferredPack_NETCoreApp_ProbesFirst() { var result = DotNetRuntimeLocator.FindAssemblyInSharedFramework( "System.Runtime", null, "Microsoft.NETCore.App"); - Assert.NotNull(result); + Assert.IsNotNull(result); Assert.Contains("Microsoft.NETCore.App", result.Path); - Assert.Equal("Microsoft.NETCore.App", result.RuntimePack); + Assert.AreEqual("Microsoft.NETCore.App", result.RuntimePack); } /// Verifies that WindowsDesktop.App preferred pack is probed first on Windows. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindAssemblyInSharedFramework_PreferredPack_WindowsDesktopApp_ProbesFirst() { - Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + TestSkip.Unless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "WindowsDesktop.App is only available on Windows"); var result = DotNetRuntimeLocator.FindAssemblyInSharedFramework( "WindowsBase", null, "Microsoft.WindowsDesktop.App"); - Assert.NotNull(result); + Assert.IsNotNull(result); Assert.Contains("Microsoft.WindowsDesktop.App", result.Path); - Assert.Equal("Microsoft.WindowsDesktop.App", result.RuntimePack); + Assert.AreEqual("Microsoft.WindowsDesktop.App", result.RuntimePack); } /// Verifies that AspNetCore.App preferred pack is probed first when available. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindAssemblyInSharedFramework_PreferredPack_AspNetCoreApp_ProbesFirst() { var result = DotNetRuntimeLocator.FindAssemblyInSharedFramework( @@ -97,7 +106,7 @@ public void FindAssemblyInSharedFramework_PreferredPack_AspNetCoreApp_ProbesFirs if (result is not null) { Assert.Contains("Microsoft.AspNetCore.App", result.Path); - Assert.Equal("Microsoft.AspNetCore.App", result.RuntimePack); + Assert.AreEqual("Microsoft.AspNetCore.App", result.RuntimePack); } } } diff --git a/tests/Dotsider.Tests/Dotsider.Tests.csproj b/tests/Dotsider.Tests/Dotsider.Tests.csproj index 8b9bf6d9..e2e7aa59 100644 --- a/tests/Dotsider.Tests/Dotsider.Tests.csproj +++ b/tests/Dotsider.Tests/Dotsider.Tests.csproj @@ -1,29 +1,19 @@ - - - - net10.0 - enable - enable - true - true - + - - - + + net10.0 + enable + enable + true + true + - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + - - - - + + + + diff --git a/tests/Dotsider.Tests/DotsiderHexRendererTests.cs b/tests/Dotsider.Tests/DotsiderHexRendererTests.cs index a384d3e6..b7b23768 100644 --- a/tests/Dotsider.Tests/DotsiderHexRendererTests.cs +++ b/tests/Dotsider.Tests/DotsiderHexRendererTests.cs @@ -5,6 +5,7 @@ namespace Dotsider.Tests; /// /// Tests for Dotsider Hex Renderer. /// +[TestClass] public class DotsiderHexRendererTests { // --- CalculateLayout tests --- @@ -12,23 +13,25 @@ public class DotsiderHexRendererTests /// /// Verifies calculate layout snaps to expected bytes per row. /// - [Theory(Timeout = 30_000)] - [InlineData(140, 32)] // Wide terminal: max snap point (4*32+11=139) - [InlineData(120, 16)] // 120 cols: (120-11)/4=27, snaps to 16 - [InlineData(80, 16)] // Standard terminal - [InlineData(50, 8)] // Narrow terminal - [InlineData(20, 1)] // Very narrow: minimum snap - [InlineData(11, 1)] // Minimum viable width (4*1+11=15, so 1 fits barely) + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow(140, 32)] // Wide terminal: max snap point (4*32+11=139) + [DataRow(120, 16)] // 120 cols: (120-11)/4=27, snaps to 16 + [DataRow(80, 16)] // Standard terminal + [DataRow(50, 8)] // Narrow terminal + [DataRow(20, 1)] // Very narrow: minimum snap + [DataRow(11, 1)] // Minimum viable width (4*1+11=15, so 1 fits barely) public void CalculateLayout_SnapsToExpectedBytesPerRow(int width, int expectedBytesPerRow) { var result = DotsiderHexRenderer.CalculateLayout(width); - Assert.Equal(expectedBytesPerRow, result); + Assert.AreEqual(expectedBytesPerRow, result); } /// /// Verifies calculate layout always returns snap point. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void CalculateLayout_AlwaysReturnsSnapPoint() { int[] snaps = [1, 8, 16, 32]; @@ -42,24 +45,26 @@ public void CalculateLayout_AlwaysReturnsSnapPoint() /// /// Verifies calculate layout never exceeds max32. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void CalculateLayout_NeverExceedsMax32() { var result = DotsiderHexRenderer.CalculateLayout(10_000); - Assert.Equal(32, result); + Assert.AreEqual(32, result); } /// /// Verifies calculate layout monotonically increases. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void CalculateLayout_MonotonicallyIncreases() { var prev = DotsiderHexRenderer.CalculateLayout(1); for (var width = 2; width <= 300; width++) { var current = DotsiderHexRenderer.CalculateLayout(width); - Assert.True(current >= prev, $"Width {width}: {current} < {prev}"); + Assert.IsGreaterThanOrEqualTo(prev, current, $"Width {width}: {current} < {prev}"); prev = current; } } @@ -69,18 +74,20 @@ public void CalculateLayout_MonotonicallyIncreases() /// /// Verifies get byte category fg ansi null byte returns null color. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetByteCategoryFgAnsi_NullByte_ReturnsNullColor() { var result = DotsiderHexRenderer.GetByteCategoryFgAnsi(0x00); - Assert.NotNull(result); + Assert.IsNotNull(result); Assert.Contains("\x1b[", result); // ANSI escape } /// /// Verifies get byte category fg ansi printable ascii returns printable color. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetByteCategoryFgAnsi_PrintableAscii_ReturnsPrintableColor() { var letterA = DotsiderHexRenderer.GetByteCategoryFgAnsi((byte)'A'); @@ -88,55 +95,59 @@ public void GetByteCategoryFgAnsi_PrintableAscii_ReturnsPrintableColor() var tilde = DotsiderHexRenderer.GetByteCategoryFgAnsi(0x7E); // All printable bytes should get the same color - Assert.Equal(letterA, digit0); - Assert.Equal(letterA, tilde); + Assert.AreEqual(letterA, digit0); + Assert.AreEqual(letterA, tilde); } /// /// Verifies get byte category fg ansi whitespace returns whitespace color. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetByteCategoryFgAnsi_Whitespace_ReturnsWhitespaceColor() { var tab = DotsiderHexRenderer.GetByteCategoryFgAnsi(0x09); var lf = DotsiderHexRenderer.GetByteCategoryFgAnsi(0x0A); var cr = DotsiderHexRenderer.GetByteCategoryFgAnsi(0x0D); - Assert.Equal(tab, lf); - Assert.Equal(tab, cr); + Assert.AreEqual(tab, lf); + Assert.AreEqual(tab, cr); } /// /// Verifies get byte category fg ansi control returns control color. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetByteCategoryFgAnsi_Control_ReturnsControlColor() { var bel = DotsiderHexRenderer.GetByteCategoryFgAnsi(0x07); var esc = DotsiderHexRenderer.GetByteCategoryFgAnsi(0x1B); // Control chars (excluding whitespace) get the same color - Assert.Equal(bel, esc); + Assert.AreEqual(bel, esc); } /// /// Verifies get byte category fg ansi high byte returns high byte color. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetByteCategoryFgAnsi_HighByte_ReturnsHighByteColor() { var result = DotsiderHexRenderer.GetByteCategoryFgAnsi(0xFF); - Assert.NotNull(result); + Assert.IsNotNull(result); // Should differ from printable var printable = DotsiderHexRenderer.GetByteCategoryFgAnsi((byte)'A'); - Assert.NotEqual(printable, result); + Assert.AreNotEqual(printable, result); } /// /// Verifies get byte category fg ansi all categories are distinct. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetByteCategoryFgAnsi_AllCategories_AreDistinct() { var nullColor = DotsiderHexRenderer.GetByteCategoryFgAnsi(0x00); @@ -146,6 +157,6 @@ public void GetByteCategoryFgAnsi_AllCategories_AreDistinct() var high = DotsiderHexRenderer.GetByteCategoryFgAnsi(0xFF); var colors = new HashSet { nullColor, whitespace, control, printable, high }; - Assert.Equal(5, colors.Count); + Assert.HasCount(5, colors); } } diff --git a/tests/Dotsider.Tests/DotsiderStateTests.cs b/tests/Dotsider.Tests/DotsiderStateTests.cs index ab322d15..473fead2 100644 --- a/tests/Dotsider.Tests/DotsiderStateTests.cs +++ b/tests/Dotsider.Tests/DotsiderStateTests.cs @@ -7,9 +7,11 @@ namespace Dotsider.Tests; /// /// Tests for Dotsider State. /// -[Collection("SampleAssemblies")] -public class DotsiderStateTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class DotsiderStateTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _app; @@ -31,298 +33,321 @@ private Hex1bApp CreateApp() /// /// Verifies construct from hello world has correct file name. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ConstructFromHelloWorld_HasCorrectFileName() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.HelloWorldDll); - Assert.Equal("HelloWorld.dll", state.Analyzer.FileName); + using var state = new DotsiderState(app, Samples.HelloWorldDll); + Assert.AreEqual("HelloWorld.dll", state.Analyzer.FileName); } /// /// Verifies has entry point true for exe. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HasEntryPoint_TrueForExe() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.HelloWorldDll); - Assert.True(state.HasEntryPoint); + using var state = new DotsiderState(app, Samples.HelloWorldDll); + Assert.IsTrue(state.HasEntryPoint); } /// /// Verifies has entry point false for library. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HasEntryPoint_FalseForLibrary() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); - Assert.False(state.HasEntryPoint); + using var state = new DotsiderState(app, Samples.RichLibraryDll); + Assert.IsFalse(state.HasEntryPoint); } /// /// Verifies has entry point true for complex app. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HasEntryPoint_TrueForComplexApp() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.ComplexAppDll); - Assert.True(state.HasEntryPoint); + using var state = new DotsiderState(app, Samples.ComplexAppDll); + Assert.IsTrue(state.HasEntryPoint); } /// /// Verifies has entry point false for empty lib. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HasEntryPoint_FalseForEmptyLib() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.EmptyLibDll); - Assert.False(state.HasEntryPoint); + using var state = new DotsiderState(app, Samples.EmptyLibDll); + Assert.IsFalse(state.HasEntryPoint); } /// /// Verifies has entry point false for native lib. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HasEntryPoint_FalseForNativeLib() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.NativeLibDll); - Assert.False(state.HasEntryPoint); + using var state = new DotsiderState(app, Samples.NativeLibDll); + Assert.IsFalse(state.HasEntryPoint); } /// - /// Verifies is native aot false for all managed samples. + /// Verifies is native aot false for all managed Samples. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void IsNativeAot_FalseForAllManagedSamples() { var app = CreateApp(); - string[] paths = [samples.HelloWorldDll, samples.RichLibraryDll, samples.ComplexAppDll, - samples.MinimalApiDll, samples.NativeLibDll, samples.EmptyLibDll]; + string[] paths = [Samples.HelloWorldDll, Samples.RichLibraryDll, Samples.ComplexAppDll, + Samples.MinimalApiDll, Samples.NativeLibDll, Samples.EmptyLibDll]; foreach (var path in paths) { using var state = new DotsiderState(app, path); - Assert.False(state.IsNativeAot, $"IsNativeAot should be false for {Path.GetFileName(path)}"); + Assert.IsFalse(state.IsNativeAot, $"IsNativeAot should be false for {Path.GetFileName(path)}"); } } /// /// Verifies push assembly changes analyzer. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PushAssembly_ChangesAnalyzer() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.HelloWorldDll); - Assert.Equal("HelloWorld.dll", state.Analyzer.FileName); - Assert.True(state.PushAssembly(samples.RichLibraryDll)); - Assert.Equal("RichLibrary.dll", state.Analyzer.FileName); - Assert.Single(state.NavigationStack); + using var state = new DotsiderState(app, Samples.HelloWorldDll); + Assert.AreEqual("HelloWorld.dll", state.Analyzer.FileName); + Assert.IsTrue(state.PushAssembly(Samples.RichLibraryDll)); + Assert.AreEqual("RichLibrary.dll", state.Analyzer.FileName); + Assert.ContainsSingle(state.NavigationStack); } /// /// Verifies pop assembly restores previous. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PopAssembly_RestoresPrevious() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.HelloWorldDll); - Assert.True(state.PushAssembly(samples.RichLibraryDll)); + using var state = new DotsiderState(app, Samples.HelloWorldDll); + Assert.IsTrue(state.PushAssembly(Samples.RichLibraryDll)); state.PopAssembly(); - Assert.Equal("HelloWorld.dll", state.Analyzer.FileName); - Assert.Empty(state.NavigationStack); + Assert.AreEqual("HelloWorld.dll", state.Analyzer.FileName); + Assert.IsEmpty(state.NavigationStack); } /// /// Verifies push assembly invalid path returns false and sets error. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PushAssembly_InvalidPath_ReturnsFalseAndSetsError() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.HelloWorldDll); - Assert.False(state.PushAssembly("/nonexistent/fake.dll")); - Assert.Equal("HelloWorld.dll", state.Analyzer.FileName); - Assert.Empty(state.NavigationStack); - Assert.NotNull(state.NavigationError); + using var state = new DotsiderState(app, Samples.HelloWorldDll); + Assert.IsFalse(state.PushAssembly("/nonexistent/fake.dll")); + Assert.AreEqual("HelloWorld.dll", state.Analyzer.FileName); + Assert.IsEmpty(state.NavigationStack); + Assert.IsNotNull(state.NavigationError); } /// /// Verifies push assembly depth limit returns false at max. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PushAssembly_DepthLimit_ReturnsFalseAtMax() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.HelloWorldDll); + using var state = new DotsiderState(app, Samples.HelloWorldDll); // Push to the limit (alternating two assemblies) for (var i = 0; i < DotsiderState.MaxNavigationDepth; i++) { - var path = i % 2 == 0 ? samples.RichLibraryDll : samples.EmptyLibDll; - Assert.True(state.PushAssembly(path), $"Push {i + 1} should succeed"); + var path = i % 2 == 0 ? Samples.RichLibraryDll : Samples.EmptyLibDll; + Assert.IsTrue(state.PushAssembly(path), $"Push {i + 1} should succeed"); } // Next push should fail - Assert.False(state.PushAssembly(samples.ComplexAppDll)); - Assert.Contains("depth limit", state.NavigationError); + Assert.IsFalse(state.PushAssembly(Samples.ComplexAppDll)); + Assert.Contains("depth limit", state.NavigationError!); } /// /// Verifies push assembly success clears error. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PushAssembly_SuccessClearsError() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.HelloWorldDll); + using var state = new DotsiderState(app, Samples.HelloWorldDll); // Trigger an error first state.PushAssembly("/nonexistent/fake.dll"); - Assert.NotNull(state.NavigationError); + Assert.IsNotNull(state.NavigationError); // Successful push clears it - Assert.True(state.PushAssembly(samples.RichLibraryDll)); - Assert.Null(state.NavigationError); + Assert.IsTrue(state.PushAssembly(Samples.RichLibraryDll)); + Assert.IsNull(state.NavigationError); } /// /// Verifies pop assembly clears navigation error. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PopAssembly_ClearsNavigationError() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.HelloWorldDll); - Assert.True(state.PushAssembly(samples.RichLibraryDll)); + using var state = new DotsiderState(app, Samples.HelloWorldDll); + Assert.IsTrue(state.PushAssembly(Samples.RichLibraryDll)); // Set an error, then pop state.PushAssembly("/nonexistent/fake.dll"); - Assert.NotNull(state.NavigationError); + Assert.IsNotNull(state.NavigationError); state.PopAssembly(); - Assert.Null(state.NavigationError); + Assert.IsNull(state.NavigationError); } /// /// Verifies push assembly bad image returns false and preserves state. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PushAssembly_BadImage_ReturnsFalseAndPreservesState() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.HelloWorldDll); - Assert.False(state.PushAssembly(samples.NonDotNetBinaryPath)); - Assert.Equal("HelloWorld.dll", state.Analyzer.FileName); - Assert.Empty(state.NavigationStack); - Assert.Contains("Cannot open assembly", state.NavigationError); + using var state = new DotsiderState(app, Samples.HelloWorldDll); + Assert.IsFalse(state.PushAssembly(Samples.NonDotNetBinaryPath)); + Assert.AreEqual("HelloWorld.dll", state.Analyzer.FileName); + Assert.IsEmpty(state.NavigationStack); + Assert.Contains("Cannot open assembly", state.NavigationError!); } /// /// Verifies push assembly unauthorized access returns false. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PushAssembly_UnauthorizedAccess_ReturnsFalse() { // File.ReadAllBytes on a directory throws UnauthorizedAccessException on all platforms var app = CreateApp(); - using var state = new DotsiderState(app, samples.HelloWorldDll); - Assert.False(state.PushAssembly(Path.GetTempPath())); - Assert.Equal("HelloWorld.dll", state.Analyzer.FileName); - Assert.Empty(state.NavigationStack); - Assert.NotNull(state.NavigationError); + using var state = new DotsiderState(app, Samples.HelloWorldDll); + Assert.IsFalse(state.PushAssembly(Path.GetTempPath())); + Assert.AreEqual("HelloWorld.dll", state.Analyzer.FileName); + Assert.IsEmpty(state.NavigationStack); + Assert.IsNotNull(state.NavigationError); } /// /// Verifies pop assembly empty stack is no op. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PopAssembly_EmptyStack_IsNoOp() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.HelloWorldDll); + using var state = new DotsiderState(app, Samples.HelloWorldDll); state.PopAssembly(); - Assert.Equal("HelloWorld.dll", state.Analyzer.FileName); - Assert.Empty(state.NavigationStack); + Assert.AreEqual("HelloWorld.dll", state.Analyzer.FileName); + Assert.IsEmpty(state.NavigationStack); } /// /// Verifies get active strings returns non empty. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetActiveStrings_ReturnsNonEmpty() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.StringsSourceTab = 1; // Metadata strings var strings = state.GetActiveStrings(); - Assert.NotEmpty(strings); + Assert.IsNotEmpty(strings); } /// /// Verifies format size zero. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FormatSize_Zero() { - Assert.Equal("0 B", DotsiderState.FormatSize(0)); + Assert.AreEqual("0 B", DotsiderState.FormatSize(0)); } /// /// Verifies format size kb. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FormatSize_KB() { - Assert.Equal("1.0 KB", DotsiderState.FormatSize(1024)); + Assert.AreEqual("1.0 KB", DotsiderState.FormatSize(1024)); } /// /// Verifies format size mb. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FormatSize_MB() { - Assert.Equal("1.0 MB", DotsiderState.FormatSize(1048576)); + Assert.AreEqual("1.0 MB", DotsiderState.FormatSize(1048576)); } /// /// Verifies format size bytes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FormatSize_Bytes() { - Assert.Equal("500 B", DotsiderState.FormatSize(500)); + Assert.AreEqual("500 B", DotsiderState.FormatSize(500)); } /// /// Verifies construct from analyzer works. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ConstructFromAnalyzer_Works() { var app = CreateApp(); - var analyzer = new AssemblyAnalyzer(samples.RichLibraryDll); + var analyzer = new AssemblyAnalyzer(Samples.RichLibraryDll); using var state = new DotsiderState(app, analyzer); - Assert.Equal("RichLibrary.dll", state.Analyzer.FileName); - Assert.NotNull(state.IlDisassembler); - Assert.NotNull(state.StringExtractor); + Assert.AreEqual("RichLibrary.dll", state.Analyzer.FileName); + Assert.IsNotNull(state.IlDisassembler); + Assert.IsNotNull(state.StringExtractor); } /// /// Verifies all project types construct without error. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AllProjectTypes_ConstructWithoutError() { var app = CreateApp(); - string[] paths = [samples.HelloWorldDll, samples.RichLibraryDll, samples.ComplexAppDll, - samples.MinimalApiDll, samples.NativeLibDll, samples.EmptyLibDll, samples.RichLibraryV2Dll]; + string[] paths = [Samples.HelloWorldDll, Samples.RichLibraryDll, Samples.ComplexAppDll, + Samples.MinimalApiDll, Samples.NativeLibDll, Samples.EmptyLibDll, Samples.RichLibraryV2Dll]; foreach (var path in paths) { using var state = new DotsiderState(app, path); - Assert.NotNull(state.Analyzer); - Assert.NotNull(state.IlDisassembler); - Assert.NotNull(state.StringExtractor); + Assert.IsNotNull(state.Analyzer); + Assert.IsNotNull(state.IlDisassembler); + Assert.IsNotNull(state.StringExtractor); } } @@ -331,50 +356,54 @@ public void AllProjectTypes_ConstructWithoutError() /// /// Verifies navigate to tab switches current tab. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigateToTab_SwitchesCurrentTab() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.General; state.NavigateToTab(TabId.IlInspector); - Assert.Equal(TabId.IlInspector, state.CurrentTab); + Assert.AreEqual(TabId.IlInspector, state.CurrentTab); } /// /// Verifies navigate to tab same tab no op. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigateToTab_SameTab_NoOp() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.HexDump; state.NavigateToTab(TabId.HexDump); - Assert.Equal(TabId.HexDump, state.CurrentTab); + Assert.AreEqual(TabId.HexDump, state.CurrentTab); } /// /// Verifies navigate to tab to il inspector switches tab. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigateToTab_ToIlInspector_SwitchesTab() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.PeMetadata; state.NavigateToTab(TabId.IlInspector); - Assert.Equal(TabId.IlInspector, state.CurrentTab); + Assert.AreEqual(TabId.IlInspector, state.CurrentTab); } /// /// Verifies navigate to tab il round trip preserves editor state. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigateToTab_IlRoundTrip_PreservesEditorState() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.General; state.NavigateToTab(TabId.IlInspector); @@ -386,8 +415,8 @@ public void NavigateToTab_IlRoundTrip_PreservesEditorState() state.NavigateToTab(TabId.IlInspector); // Editor state survives round-trip (Responsive preserves nodes) - Assert.NotNull(state.IlEditorState); - Assert.Equal(TabId.IlInspector, state.CurrentTab); + Assert.IsNotNull(state.IlEditorState); + Assert.AreEqual(TabId.IlInspector, state.CurrentTab); } /// @@ -396,55 +425,58 @@ public void NavigateToTab_IlRoundTrip_PreservesEditorState() /// IL tab-entry focus behavior — the table uses this key to deterministically /// focus the correct row. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigateToIlMethod_SetsIlFocusedTreeKey() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); // Initially null - Assert.Null(state.IlFocusedTreeKey); + Assert.IsNull(state.IlFocusedTreeKey); state.CurrentTab = TabId.PeMetadata; var method = state.Analyzer.MethodDefs.First(m => m.Rva > 0); state.NavigateToIlMethod(method); // Focused key must point to the jumped-to method row - Assert.Equal($"method:{method.Token}", state.IlFocusedTreeKey); - Assert.Equal(TabId.IlInspector, state.CurrentTab); + Assert.AreEqual($"method:{method.Token}", state.IlFocusedTreeKey); + Assert.AreEqual(TabId.IlInspector, state.CurrentTab); } /// /// Verifies navigate to il method sets state and switches tab. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigateToIlMethod_SetsStateAndSwitchesTab() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.PeMetadata; state.PeSubTab = PeSubTabId.MethodDef; var method = state.Analyzer.MethodDefs.First(m => m.Rva > 0); state.NavigateToIlMethod(method); - Assert.Equal(TabId.IlInspector, state.CurrentTab); - Assert.Equal(method, state.IlSelectedMethod); - Assert.NotNull(state.CrossViewBackTarget); - Assert.Equal(TabId.PeMetadata, state.CrossViewBackTarget!.Value.Tab); - Assert.Equal(PeSubTabId.MethodDef, state.CrossViewBackTarget!.Value.SubTab); + Assert.AreEqual(TabId.IlInspector, state.CurrentTab); + Assert.AreEqual(method, state.IlSelectedMethod); + Assert.IsNotNull(state.CrossViewBackTarget); + Assert.AreEqual(TabId.PeMetadata, state.CrossViewBackTarget!.Value.Tab); + Assert.AreEqual(PeSubTabId.MethodDef, state.CrossViewBackTarget!.Value.SubTab); // Focused tree key must point to the jumped-to method row - Assert.Equal($"method:{method.Token}", state.IlFocusedTreeKey); + Assert.AreEqual($"method:{method.Token}", state.IlFocusedTreeKey); } /// /// Verifies navigate to il method expands tree nodes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigateToIlMethod_ExpandsTreeNodes() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.PeMetadata; var method = state.Analyzer.MethodDefs.First(m => m.Rva > 0); @@ -452,18 +484,19 @@ public void NavigateToIlMethod_ExpandsTreeNodes() var typeDef = state.Analyzer.TypeDefs.First(t => t.FullName == method.DeclaringType); var ns = string.IsNullOrEmpty(typeDef.Namespace) ? "(global)" : typeDef.Namespace; - Assert.True(state.IlTreeExpansionState[$"ns:{ns}"]); - Assert.True(state.IlTreeExpansionState[$"type:{method.DeclaringType}"]); + Assert.IsTrue(state.IlTreeExpansionState[$"ns:{ns}"]); + Assert.IsTrue(state.IlTreeExpansionState[$"type:{method.DeclaringType}"]); } /// /// Verifies navigate to il method clears stale il search. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigateToIlMethod_ClearsStaleIlSearch() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.PeMetadata; var ilSearch = state.Search[TabId.IlInspector]; @@ -475,84 +508,89 @@ public void NavigateToIlMethod_ClearsStaleIlSearch() var method = state.Analyzer.MethodDefs.First(m => m.Rva > 0); state.NavigateToIlMethod(method); - Assert.False(ilSearch.IsActive); - Assert.False(ilSearch.IsConfirmed); - Assert.Null(ilSearch.Query); - Assert.Equal(-1, ilSearch.MatchCount); + Assert.IsFalse(ilSearch.IsActive); + Assert.IsFalse(ilSearch.IsConfirmed); + Assert.IsNull(ilSearch.Query); + Assert.AreEqual(-1, ilSearch.MatchCount); } /// /// Verifies rva to file offset returns correct offset. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RvaToFileOffset_ReturnsCorrectOffset() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); // Find a method with an RVA that falls within a section var method = state.Analyzer.MethodDefs.First(m => m.Rva > 0); var offset = state.RvaToFileOffset(method.Rva); - Assert.True(offset >= 0, "RVA should resolve to a valid file offset"); + Assert.IsGreaterThanOrEqualTo(0, offset, "RVA should resolve to a valid file offset"); // Verify the offset falls within the raw data range of some section var foundSection = state.Analyzer.Sections.Any(s => offset >= s.RawDataOffset && offset < s.RawDataOffset + s.RawDataSize); - Assert.True(foundSection, "File offset should be within a section's raw data"); + Assert.IsTrue(foundSection, "File offset should be within a section's raw data"); } /// /// Verifies rva to file offset invalid rva returns negative. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RvaToFileOffset_InvalidRva_ReturnsNegative() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); - Assert.Equal(-1, state.RvaToFileOffset(0x7FFFFFFF)); + using var state = new DotsiderState(app, Samples.RichLibraryDll); + Assert.AreEqual(-1, state.RvaToFileOffset(0x7FFFFFFF)); } /// /// Verifies navigate to hex offset sets state and switches tab. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigateToHexOffset_SetsStateAndSwitchesTab() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.IlInspector; var method = state.Analyzer.MethodDefs.First(m => m.Rva > 0); state.NavigateToHexOffset(method.Rva); - Assert.Equal(TabId.HexDump, state.CurrentTab); - Assert.NotNull(state.HexScrollTarget); - Assert.NotNull(state.CrossViewBackTarget); - Assert.Equal(TabId.IlInspector, state.CrossViewBackTarget!.Value.Tab); + Assert.AreEqual(TabId.HexDump, state.CurrentTab); + Assert.IsNotNull(state.HexScrollTarget); + Assert.IsNotNull(state.CrossViewBackTarget); + Assert.AreEqual(TabId.IlInspector, state.CrossViewBackTarget!.Value.Tab); } /// /// Verifies navigate to hex offset sets cursor position. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigateToHexOffset_SetsCursorPosition() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.IlInspector; var method = state.Analyzer.MethodDefs.First(m => m.Rva > 0); var expectedOffset = state.RvaToFileOffset(method.Rva); state.NavigateToHexOffset(method.Rva); - Assert.Equal((int)expectedOffset, state.HexEditorState.ByteCursorOffset); - Assert.Equal(expectedOffset, state.HexScrollTarget); + Assert.AreEqual((int)expectedOffset, state.HexEditorState.ByteCursorOffset); + Assert.AreEqual(expectedOffset, state.HexScrollTarget); } /// /// Verifies a raw Wasm function can jump to its file-backed bytes in the Hex Dump. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigateToHexFileOffset_WasmFunction_SetsCursorPosition() { var wasmPath = GetWasmNativePath(); @@ -564,132 +602,138 @@ public void NavigateToHexFileOffset_WasmFunction_SetsCursorPosition() state.IlSelectedNativeSymbol = symbol; state.NavigateToHexFileOffset(symbol.FileOffset!.Value); - Assert.Equal(TabId.HexDump, state.CurrentTab); - Assert.Equal((int)symbol.FileOffset.Value, state.HexEditorState.ByteCursorOffset); - Assert.Equal(symbol.FileOffset.Value, state.HexScrollTarget); - Assert.NotNull(state.CrossViewBackTarget); - Assert.Equal(TabId.IlInspector, state.CrossViewBackTarget!.Value.Tab); + Assert.AreEqual(TabId.HexDump, state.CurrentTab); + Assert.AreEqual((int)symbol.FileOffset.Value, state.HexEditorState.ByteCursorOffset); + Assert.AreEqual(symbol.FileOffset.Value, state.HexScrollTarget); + Assert.IsNotNull(state.CrossViewBackTarget); + Assert.AreEqual(TabId.IlInspector, state.CrossViewBackTarget!.Value.Tab); } /// /// Verifies navigate to hex offset invalid rva no tab switch. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigateToHexOffset_InvalidRva_NoTabSwitch() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.IlInspector; state.NavigateToHexOffset(0x7FFFFFFF); - Assert.Equal(TabId.IlInspector, state.CurrentTab); - Assert.Null(state.CrossViewBackTarget); + Assert.AreEqual(TabId.IlInspector, state.CurrentTab); + Assert.IsNull(state.CrossViewBackTarget); } /// /// Verifies navigate back restores previous tab. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigateBack_RestoresPreviousTab() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.PeMetadata; state.PeSubTab = PeSubTabId.TypeDef; var method = state.Analyzer.MethodDefs.First(m => m.Rva > 0); state.NavigateToIlMethod(method); - Assert.Equal(TabId.IlInspector, state.CurrentTab); + Assert.AreEqual(TabId.IlInspector, state.CurrentTab); state.NavigateBack(); - Assert.Equal(TabId.PeMetadata, state.CurrentTab); - Assert.Equal(PeSubTabId.TypeDef, state.PeSubTab); - Assert.Null(state.CrossViewBackTarget); + Assert.AreEqual(TabId.PeMetadata, state.CurrentTab); + Assert.AreEqual(PeSubTabId.TypeDef, state.PeSubTab); + Assert.IsNull(state.CrossViewBackTarget); } /// /// Verifies navigate back no target no op. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigateBack_NoTarget_NoOp() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.IlInspector; state.NavigateBack(); - Assert.Equal(TabId.IlInspector, state.CurrentTab); + Assert.AreEqual(TabId.IlInspector, state.CurrentTab); } /// /// Verifies push assembly clears cross view back target. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PushAssembly_ClearsCrossViewBackTarget() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.PeMetadata; // Create a cross-view back target var method = state.Analyzer.MethodDefs.First(m => m.Rva > 0); state.NavigateToIlMethod(method); - Assert.NotNull(state.CrossViewBackTarget); + Assert.IsNotNull(state.CrossViewBackTarget); // Push a new assembly — should clear the stale back target - state.PushAssembly(samples.HelloWorldDll); - Assert.Null(state.CrossViewBackTarget); + state.PushAssembly(Samples.HelloWorldDll); + Assert.IsNull(state.CrossViewBackTarget); } /// /// Verifies pop assembly clears cross view back target. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PopAssembly_ClearsCrossViewBackTarget() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); // Push first, then create back target - state.PushAssembly(samples.HelloWorldDll); + state.PushAssembly(Samples.HelloWorldDll); state.CurrentTab = TabId.PeMetadata; var method = state.Analyzer.MethodDefs.First(m => m.Rva > 0); state.NavigateToIlMethod(method); - Assert.NotNull(state.CrossViewBackTarget); + Assert.IsNotNull(state.CrossViewBackTarget); // Pop — should clear back target state.PopAssembly(); - Assert.Null(state.CrossViewBackTarget); + Assert.IsNull(state.CrossViewBackTarget); } /// /// Verifies navigate to il method then hex then back restores il. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigateToIlMethod_ThenHex_ThenBack_RestoresIl() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.PeMetadata; state.PeSubTab = PeSubTabId.MethodDef; // PE → IL Inspector var method = state.Analyzer.MethodDefs.First(m => m.Rva > 0); state.NavigateToIlMethod(method); - Assert.Equal(TabId.IlInspector, state.CurrentTab); + Assert.AreEqual(TabId.IlInspector, state.CurrentTab); // IL Inspector → Hex Dump state.NavigateToHexOffset(method.Rva); - Assert.Equal(TabId.HexDump, state.CurrentTab); + Assert.AreEqual(TabId.HexDump, state.CurrentTab); // Back target should be IL Inspector (most recent navigation) - Assert.Equal(TabId.IlInspector, state.CrossViewBackTarget!.Value.Tab); + Assert.AreEqual(TabId.IlInspector, state.CrossViewBackTarget!.Value.Tab); // Back → IL Inspector state.NavigateBack(); - Assert.Equal(TabId.IlInspector, state.CurrentTab); + Assert.AreEqual(TabId.IlInspector, state.CurrentTab); // The PE Metadata frame underneath must remain reachable via a second // Esc — chained cross-view jumps unwind one frame at a time, with the // exact origin sub-tab preserved. - Assert.Equal((TabId.PeMetadata, PeSubTabId.MethodDef), state.CrossViewBackTarget); + Assert.AreEqual((TabId.PeMetadata, PeSubTabId.MethodDef), state.CrossViewBackTarget); } // --- Apphost Detection --- @@ -697,107 +741,114 @@ public void NavigateToIlMethod_ThenHex_ThenBack_RestoresIl() /// /// Verifies construct from apphost exe sets apphost dialog state. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ConstructFromApphostExe_SetsApphostDialogState() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.HelloWorldExe); + using var state = new DotsiderState(app, Samples.HelloWorldExe); - Assert.True(state.ApphostDialogOpen); - Assert.NotNull(state.ApphostCompanionDllPath); + Assert.IsTrue(state.ApphostDialogOpen); + Assert.IsNotNull(state.ApphostCompanionDllPath); Assert.EndsWith(".dll", state.ApphostCompanionDllPath!, StringComparison.OrdinalIgnoreCase); } /// /// Verifies construct from managed dll no apphost dialog. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ConstructFromManagedDll_NoApphostDialog() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.HelloWorldDll); + using var state = new DotsiderState(app, Samples.HelloWorldDll); - Assert.False(state.ApphostDialogOpen); - Assert.Null(state.ApphostCompanionDllPath); + Assert.IsFalse(state.ApphostDialogOpen); + Assert.IsNull(state.ApphostCompanionDllPath); } /// /// Verifies push assembly from apphost to companion dll works. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PushAssembly_FromApphostToCompanionDll_Works() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.HelloWorldExe); - Assert.False(state.Analyzer.HasMetadata); + using var state = new DotsiderState(app, Samples.HelloWorldExe); + Assert.IsFalse(state.Analyzer.HasMetadata); - Assert.True(state.PushAssembly(state.ApphostCompanionDllPath!)); + Assert.IsTrue(state.PushAssembly(state.ApphostCompanionDllPath!)); - Assert.True(state.Analyzer.HasMetadata); - Assert.Equal("HelloWorld.dll", state.Analyzer.FileName); - Assert.Single(state.NavigationStack); + Assert.IsTrue(state.Analyzer.HasMetadata); + Assert.AreEqual("HelloWorld.dll", state.Analyzer.FileName); + Assert.ContainsSingle(state.NavigationStack); } /// /// Verifies tab 3 keeps its managed label for ordinary IL inspection. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void IlInspectorTabLabel_ManagedAssembly_IsIlInspector() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.HelloWorldDll); + using var state = new DotsiderState(app, Samples.HelloWorldDll); - Assert.Equal(IlInspectorTabLabel.IlInspector, IlInspectorTabLabel.For(state)); + Assert.AreEqual(IlInspectorTabLabel.IlInspector, IlInspectorTabLabel.For(state)); } /// /// Verifies tab 3 names the native-only AOT surface as disassembly. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void IlInspectorTabLabel_NativeAotWithoutAttachment_IsDisassembly() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); var app = CreateApp(); - using var state = new DotsiderState(app, samples.NativeAotConsoleExe!); + using var state = new DotsiderState(app, Samples.NativeAotConsoleExe!); - Assert.Equal(IlInspectorTabLabel.Disassembly, IlInspectorTabLabel.For(state)); + Assert.AreEqual(IlInspectorTabLabel.Disassembly, IlInspectorTabLabel.For(state)); } /// /// Verifies tab 3 names ReadyToRun as an IL plus native surface. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void IlInspectorTabLabel_ReadyToRun_IsIlAndNative() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, "ReadyToRun crossgen2 publish did not run on this leg."); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, "ReadyToRun crossgen2 publish did not run on this leg."); var app = CreateApp(); - using var state = new DotsiderState(app, samples.ReadyToRunConsoleDll!); + using var state = new DotsiderState(app, Samples.ReadyToRunConsoleDll!); - Assert.Equal(IlInspectorTabLabel.IlAndNative, IlInspectorTabLabel.For(state)); + Assert.AreEqual(IlInspectorTabLabel.IlAndNative, IlInspectorTabLabel.For(state)); } /// /// Verifies the pre-ILC sidecar toggle switches tab 3 between paired IL/native and native disassembly. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void IlInspectorTabLabel_PreIlcAttachment_FollowsTreeToggle() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - Assert.SkipWhen(samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); var app = CreateApp(); - using var state = new DotsiderState(app, samples.NativeAotConsoleExe!); + using var state = new DotsiderState(app, Samples.NativeAotConsoleExe!); - Assert.True(state.AttachPreIlc()); - Assert.Equal(IlInspectorTabLabel.IlAndNative, IlInspectorTabLabel.For(state)); + Assert.IsTrue(state.AttachPreIlc()); + Assert.AreEqual(IlInspectorTabLabel.IlAndNative, IlInspectorTabLabel.For(state)); state.IlAotTreeNativeView = true; - Assert.Equal(IlInspectorTabLabel.Disassembly, IlInspectorTabLabel.For(state)); + Assert.AreEqual(IlInspectorTabLabel.Disassembly, IlInspectorTabLabel.For(state)); } /// @@ -805,17 +856,17 @@ public void IlInspectorTabLabel_PreIlcAttachment_FollowsTreeToggle() /// public void Dispose() { + GC.SuppressFinalize(this); _app?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); - GC.SuppressFinalize(this); } - private string GetWasmNativePath() + private static string GetWasmNativePath() { - Assert.SkipWhen(samples.WasmConsoleNativeWasm is null && samples.ReadyToRunConsoleWasmNativeWasm is null, + TestSkip.When(Samples.WasmConsoleNativeWasm is null && Samples.ReadyToRunConsoleWasmNativeWasm is null, "browser-wasm sample publish did not produce dotnet.native.wasm on this leg."); - return samples.WasmConsoleNativeWasm ?? samples.ReadyToRunConsoleWasmNativeWasm!; + return Samples.WasmConsoleNativeWasm ?? Samples.ReadyToRunConsoleWasmNativeWasm!; } } diff --git a/tests/Dotsider.Tests/DotsiderThemeTests.cs b/tests/Dotsider.Tests/DotsiderThemeTests.cs index 109dbacb..f83ab5b5 100644 --- a/tests/Dotsider.Tests/DotsiderThemeTests.cs +++ b/tests/Dotsider.Tests/DotsiderThemeTests.cs @@ -5,6 +5,7 @@ namespace Dotsider.Tests; /// /// Tests for Dotsider Theme. /// +[TestClass] public class DotsiderThemeTests { /// @@ -14,14 +15,14 @@ public class DotsiderThemeTests /// (transparent cells inherit; explicit cells don't), causing broken row highlighting /// in the live demo's xterm.js terminal. /// - [Fact] + [TestMethod] public void Theme_DoesNotSetGlobalBackground() { var theme = DotsiderTheme.Create(); var bg = theme.GetGlobalBackground(); - Assert.True(bg.IsDefault, "GlobalTheme.BackgroundColor must not be set — " + + Assert.IsTrue(bg.IsDefault, "GlobalTheme.BackgroundColor must not be set — " + "it breaks table row highlighting in xterm.js by preventing transparent " + "background compositing. The terminal background is set by the terminal " + "emulator itself (xterm.js theme / native terminal profile)."); diff --git a/tests/Dotsider.Tests/DwarfLineProgramTests.cs b/tests/Dotsider.Tests/DwarfLineProgramTests.cs index 9d0d127b..ee0f33d7 100644 --- a/tests/Dotsider.Tests/DwarfLineProgramTests.cs +++ b/tests/Dotsider.Tests/DwarfLineProgramTests.cs @@ -7,6 +7,7 @@ namespace Dotsider.Tests; /// file-table shapes, the row state machine (special, standard, and extended opcodes), and the /// decl-primary source attribution — driven with hand-built .debug_line blobs. /// +[TestClass] public class DwarfLineProgramTests { private static readonly byte[] StandardLengths = [0, 1, 1, 1, 1, 0, 0, 0, 1, 0, 0, 1]; @@ -41,7 +42,8 @@ private static byte[] Program( /// Verifies a v4 program joins directories into file names, decodes copy/advance rows, ends /// coverage at the end-sequence row, and attributes decl-first with row fallback. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Parse_V4_RowsAndDeclPrimaryAttribution() { var ops = new DwarfBlob() @@ -58,25 +60,25 @@ public void Parse_V4_RowsAndDeclPrimaryAttribution() var line = Program(4, ops, ["src"], [("main.cs", 1), ("/abs/other.cs", 1)]); var program = DwarfLineProgram.Parse(Sections(line), 0); - Assert.NotNull(program); - Assert.Equal("src/main.cs", program.FileName(1)); - Assert.Equal("/abs/other.cs", program.FileName(2)); // rooted names are not joined - Assert.Null(program.FileName(0)); // v4 tables are 1-based - Assert.Null(program.FileName(3)); - - Assert.True(program.TryFindLine(0x1000, out var file, out var lineNo)); - Assert.Equal("src/main.cs", file); - Assert.Equal(10, lineNo); - Assert.True(program.TryFindLine(0x101F, out _, out lineNo)); - Assert.Equal(10, lineNo); - Assert.True(program.TryFindLine(0x102F, out _, out lineNo)); - Assert.Equal(15, lineNo); - Assert.False(program.TryFindLine(0x1030, out _, out _)); // past the sequence - Assert.False(program.TryFindLine(0x0FFF, out _, out _)); // before it - - Assert.Equal(("src/main.cs", 42), program.ResolveSource(1, 42, 0x1000)); // decl primary - Assert.Equal(("src/main.cs", 15), program.ResolveSource(-1, 0, 0x1024)); // row fallback - Assert.Equal(("src/main.cs", 10), program.ResolveSource(1, 0, 0x1000)); // mixed + Assert.IsNotNull(program); + Assert.AreEqual("src/main.cs", program.FileName(1)); + Assert.AreEqual("/abs/other.cs", program.FileName(2)); // rooted names are not joined + Assert.IsNull(program.FileName(0)); // v4 tables are 1-based + Assert.IsNull(program.FileName(3)); + + Assert.IsTrue(program.TryFindLine(0x1000, out var file, out var lineNo)); + Assert.AreEqual("src/main.cs", file); + Assert.AreEqual(10, lineNo); + Assert.IsTrue(program.TryFindLine(0x101F, out _, out lineNo)); + Assert.AreEqual(10, lineNo); + Assert.IsTrue(program.TryFindLine(0x102F, out _, out lineNo)); + Assert.AreEqual(15, lineNo); + Assert.IsFalse(program.TryFindLine(0x1030, out _, out _)); // past the sequence + Assert.IsFalse(program.TryFindLine(0x0FFF, out _, out _)); // before it + + Assert.AreEqual(("src/main.cs", 42), program.ResolveSource(1, 42, 0x1000)); // decl primary + Assert.AreEqual(("src/main.cs", 15), program.ResolveSource(-1, 0, 0x1024)); // row fallback + Assert.AreEqual(("src/main.cs", 10), program.ResolveSource(1, 0, 0x1000)); // mixed } /// @@ -84,7 +86,8 @@ public void Parse_V4_RowsAndDeclPrimaryAttribution() /// fixed_advance_pc, and that an unknown standard opcode is skipped by its declared /// operand count. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Parse_SpecialAndPcOpcodes_AdvanceExactly() { // opcode_base 14: op 13 is an unknown standard opcode declared with 2 operands. @@ -103,18 +106,19 @@ public void Parse_SpecialAndPcOpcodes_AdvanceExactly() lineBase: -3, lineRange: 6, opcodeBase: 14, standardLengths: lengths); var program = DwarfLineProgram.Parse(Sections(line), 0); - Assert.NotNull(program); - Assert.True(program.TryFindLine(0x2008, out var file, out var lineNo)); - Assert.Equal("a.c", file); // directory index 0 resolves to the bare name - Assert.Equal(3, lineNo); - Assert.True(program.TryFindLine(0x203F, out _, out lineNo)); - Assert.Equal(3, lineNo); - Assert.True(program.TryFindLine(0x2040, out _, out lineNo)); - Assert.Equal(4, lineNo); + Assert.IsNotNull(program); + Assert.IsTrue(program.TryFindLine(0x2008, out var file, out var lineNo)); + Assert.AreEqual("a.c", file); // directory index 0 resolves to the bare name + Assert.AreEqual(3, lineNo); + Assert.IsTrue(program.TryFindLine(0x203F, out _, out lineNo)); + Assert.AreEqual(3, lineNo); + Assert.IsTrue(program.TryFindLine(0x2040, out _, out lineNo)); + Assert.AreEqual(4, lineNo); } /// Verifies a v2 header (no maximum-operations field) still parses and decodes rows. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Parse_V2Header_DecodesRows() { var ops = new DwarfBlob() @@ -125,16 +129,17 @@ public void Parse_V2Header_DecodesRows() var program = DwarfLineProgram.Parse(Sections(Program(2, ops, [], [("f.c", 0)])), 0); - Assert.NotNull(program); - Assert.True(program.TryFindLine(0x100, out var file, out _)); - Assert.Equal("f.c", file); + Assert.IsNotNull(program); + Assert.IsTrue(program.TryFindLine(0x100, out var file, out _)); + Assert.AreEqual("f.c", file); } /// /// Verifies the v5 form-described tables: directories via line_strp, files carrying /// path, directory index, and skipped timestamp/MD5 columns, with 0-based numbering. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Parse_V5Tables_ResolveFormsAndZeroBasedFiles() { var lineStr = new DwarfBlob().CStr("proj").CStr("src").ToArray(); // offsets 0 and 5 @@ -169,19 +174,20 @@ public void Parse_V5Tables_ResolveFormsAndZeroBasedFiles() var program = DwarfLineProgram.Parse(Sections(line, lineStr: lineStr), 0); - Assert.NotNull(program); - Assert.Equal("proj/app.cs", program.FileName(0)); - Assert.Equal("src/util.cs", program.FileName(1)); - Assert.True(program.TryFindLine(0x9008, out var file, out var lineNo)); - Assert.Equal("src/util.cs", file); - Assert.Equal(1, lineNo); + Assert.IsNotNull(program); + Assert.AreEqual("proj/app.cs", program.FileName(0)); + Assert.AreEqual("src/util.cs", program.FileName(1)); + Assert.IsTrue(program.TryFindLine(0x9008, out var file, out var lineNo)); + Assert.AreEqual("src/util.cs", file); + Assert.AreEqual(1, lineNo); } /// /// Verifies DW_LNE_define_file appends to the v4 file table mid-program and rows can /// attribute to the added file. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Parse_DefineFile_AppendsToTable() { var define = new DwarfBlob().CStr("gen.c").ULeb(1).ULeb(0).ULeb(0).ToArray(); @@ -195,14 +201,15 @@ public void Parse_DefineFile_AppendsToTable() var program = DwarfLineProgram.Parse(Sections(Program(4, ops, ["inc"], [("main.c", 0)])), 0); - Assert.NotNull(program); - Assert.Equal("inc/gen.c", program.FileName(2)); - Assert.True(program.TryFindLine(0x100, out var file, out _)); - Assert.Equal("inc/gen.c", file); + Assert.IsNotNull(program); + Assert.AreEqual("inc/gen.c", program.FileName(2)); + Assert.IsTrue(program.TryFindLine(0x100, out var file, out _)); + Assert.AreEqual("inc/gen.c", file); } /// Verifies a DWARF64 header (escaped length, 8-byte header length) parses. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Parse_Dwarf64Header_DecodesRows() { var tail = new DwarfBlob().U8(1).U8(1).U8(1) @@ -221,27 +228,28 @@ public void Parse_Dwarf64Header_DecodesRows() var program = DwarfLineProgram.Parse(Sections(line), 0); - Assert.NotNull(program); - Assert.True(program.TryFindLine(0x100, out var file, out _)); - Assert.Equal("f.c", file); + Assert.IsNotNull(program); + Assert.IsTrue(program.TryFindLine(0x100, out var file, out _)); + Assert.AreEqual("f.c", file); } /// /// Verifies malformed inputs: out-of-range offsets and bad headers yield no program, and a /// program whose body breaks mid-opcode keeps the rows decoded before the damage. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Parse_Malformed_FailsClosedOrKeepsPartialRows() { - Assert.Null(DwarfLineProgram.Parse(Sections([1, 2, 3]), 0x100)); + Assert.IsNull(DwarfLineProgram.Parse(Sections([1, 2, 3]), 0x100)); var badVersion = Program(4, [], [], []); badVersion[4] = 9; // version u16 low byte - Assert.Null(DwarfLineProgram.Parse(Sections(badVersion), 0)); + Assert.IsNull(DwarfLineProgram.Parse(Sections(badVersion), 0)); var zeroRange = Program(4, [], [], []); zeroRange[14] = 0; // line_range: length(4) + version(2) + header_length(4) + 4 machine bytes - Assert.Null(DwarfLineProgram.Parse(Sections(zeroRange), 0)); + Assert.IsNull(DwarfLineProgram.Parse(Sections(zeroRange), 0)); // A row, then an extended op whose declared length overruns the unit: row survives. var ops = new DwarfBlob() @@ -250,13 +258,13 @@ public void Parse_Malformed_FailsClosedOrKeepsPartialRows() .U8(0).ULeb(200) .ToArray(); var program = DwarfLineProgram.Parse(Sections(Program(4, ops, [], [("k.c", 0)])), 0); - Assert.NotNull(program); - Assert.True(program.TryFindLine(0x500, out var file, out _)); - Assert.Equal("k.c", file); + Assert.IsNotNull(program); + Assert.IsTrue(program.TryFindLine(0x500, out var file, out _)); + Assert.AreEqual("k.c", file); // An empty program parses but covers nothing. var empty = DwarfLineProgram.Parse(Sections(Program(4, [], [], [("e.c", 0)])), 0); - Assert.NotNull(empty); - Assert.False(empty.TryFindLine(0, out _, out _)); + Assert.IsNotNull(empty); + Assert.IsFalse(empty.TryFindLine(0, out _, out _)); } } diff --git a/tests/Dotsider.Tests/DwarfRangeListsTests.cs b/tests/Dotsider.Tests/DwarfRangeListsTests.cs index f9038651..cbb8ac4d 100644 --- a/tests/Dotsider.Tests/DwarfRangeListsTests.cs +++ b/tests/Dotsider.Tests/DwarfRangeListsTests.cs @@ -7,6 +7,7 @@ namespace Dotsider.Tests; /// .debug_rnglists RLE walk — driven with hand-built section blobs covering every entry /// opcode, the base-address escapes, and the rnglistx offset-array indirection. /// +[TestClass] public class DwarfRangeListsTests { private static DwarfReader.UnitContext Unit( @@ -30,7 +31,8 @@ private static byte[] AddrTable(params ulong[] entries) /// Verifies the v4 walk sums begin/end pairs against the CU base, honors the (-1, base) /// base-address escape, and stops at the (0, 0) terminator. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TryResolve_V4Pairs_SumsAndRebases() { var ranges = new DwarfBlob() @@ -43,13 +45,14 @@ public void TryResolve_V4Pairs_SumsAndRebases() var ok = DwarfRangeLists.TryResolve(Sections(ranges: ranges), 0, isRnglistx: false, Unit(4, baseAddress: 0x1000), out var start, out var size); - Assert.True(ok); - Assert.Equal(0x1010UL, start); - Assert.Equal(0x50UL, size); + Assert.IsTrue(ok); + Assert.AreEqual(0x1010UL, start); + Assert.AreEqual(0x50UL, size); } /// Verifies the v4 walk uses 4-byte entries and the 32-bit escape for 32-bit units. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TryResolve_V4FourByteAddresses_UsesNarrowEscape() { var ranges = new DwarfBlob() @@ -62,16 +65,17 @@ public void TryResolve_V4FourByteAddresses_UsesNarrowEscape() var ok = DwarfRangeLists.TryResolve(Sections(ranges: ranges), 0, isRnglistx: false, Unit(4, addressSize: 4, baseAddress: 0x100), out var start, out var size); - Assert.True(ok); - Assert.Equal(0x110UL, start); - Assert.Equal(0x18UL, size); + Assert.IsTrue(ok); + Assert.AreEqual(0x110UL, start); + Assert.AreEqual(0x18UL, size); } /// /// Verifies the v5 walk decodes every RLE opcode: base switches (direct and via /// .debug_addr), offset pairs, start/end, start/length, and the startx forms. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TryResolve_V5AllOpcodes_SumsCoveredBytes() { var list = new DwarfBlob() @@ -90,16 +94,17 @@ public void TryResolve_V5AllOpcodes_SumsCoveredBytes() Sections(rngLists: list, addr: AddrTable(0x7000, 0x8000)), 0, isRnglistx: false, Unit(5), out var start, out var size); - Assert.True(ok); - Assert.Equal(0x5010UL, start); - Assert.Equal(0x20UL + 0x25 + 8 + 0x1000 + 4 + 0x10, size); + Assert.IsTrue(ok); + Assert.AreEqual(0x5010UL, start); + Assert.AreEqual(0x20UL + 0x25 + 8 + 0x1000 + 4 + 0x10, size); } /// /// Verifies a rnglistx index resolves through the CU's offset array — each entry an /// offset from the array base to its list — before walking the list. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TryResolve_Rnglistx_ResolvesThroughOffsetArray() { var list0 = new DwarfBlob().U8(0x07).U64(0x111).ULeb(1).U8(0x00).ToArray(); @@ -113,51 +118,52 @@ public void TryResolve_Rnglistx_ResolvesThroughOffsetArray() var ok = DwarfRangeLists.TryResolve(Sections(rngLists: section), 1, isRnglistx: true, Unit(5), out var start, out var size); - Assert.True(ok); - Assert.Equal(0x2000UL, start); - Assert.Equal(0x30UL, size); + Assert.IsTrue(ok); + Assert.AreEqual(0x2000UL, start); + Assert.AreEqual(0x30UL, size); - Assert.True(DwarfRangeLists.TryResolve(Sections(rngLists: section), 0, isRnglistx: true, + Assert.IsTrue(DwarfRangeLists.TryResolve(Sections(rngLists: section), 0, isRnglistx: true, Unit(5), out start, out size)); - Assert.Equal(0x111UL, start); - Assert.Equal(1UL, size); + Assert.AreEqual(0x111UL, start); + Assert.AreEqual(1UL, size); } /// /// Verifies malformed inputs fail closed: out-of-range offsets, unknown opcodes, a /// startx without .debug_addr, and lists that cover nothing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TryResolve_Malformed_ReturnsFalse() { // Offset beyond the section. - Assert.False(DwarfRangeLists.TryResolve(Sections(ranges: [1, 2]), 0x100, false, + Assert.IsFalse(DwarfRangeLists.TryResolve(Sections(ranges: [1, 2]), 0x100, false, Unit(4), out _, out _)); // Unknown v5 opcode. - Assert.False(DwarfRangeLists.TryResolve(Sections(rngLists: [0xAA]), 0, false, + Assert.IsFalse(DwarfRangeLists.TryResolve(Sections(rngLists: [0xAA]), 0, false, Unit(5), out _, out _)); // startx with no .debug_addr to resolve against. var startx = new DwarfBlob().U8(0x03).ULeb(0).ULeb(8).U8(0x00).ToArray(); - Assert.False(DwarfRangeLists.TryResolve(Sections(rngLists: startx), 0, false, + Assert.IsFalse(DwarfRangeLists.TryResolve(Sections(rngLists: startx), 0, false, Unit(5), out _, out _)); // Immediate terminator: nothing covered. - Assert.False(DwarfRangeLists.TryResolve(Sections(rngLists: [0x00]), 0, false, + Assert.IsFalse(DwarfRangeLists.TryResolve(Sections(rngLists: [0x00]), 0, false, Unit(5), out _, out _)); var emptyV4 = new DwarfBlob().U64(0).U64(0).ToArray(); - Assert.False(DwarfRangeLists.TryResolve(Sections(ranges: emptyV4), 0, false, + Assert.IsFalse(DwarfRangeLists.TryResolve(Sections(ranges: emptyV4), 0, false, Unit(4), out _, out _)); // rnglistx index beyond the offset array. var tiny = new DwarfBlob().U32(0).U16(5).U8(8).U8(0).U32(0).ToArray(); - Assert.False(DwarfRangeLists.TryResolve(Sections(rngLists: tiny), 5, true, + Assert.IsFalse(DwarfRangeLists.TryResolve(Sections(rngLists: tiny), 5, true, Unit(5), out _, out _)); // Truncated v4 list (no terminator) keeps nothing rather than throwing. var truncated = new DwarfBlob().U64(0x10).U64(0x20).ToArray(); - Assert.False(DwarfRangeLists.TryResolve(Sections(ranges: truncated), 0, false, + Assert.IsFalse(DwarfRangeLists.TryResolve(Sections(ranges: truncated), 0, false, Unit(4), out _, out _)); } } diff --git a/tests/Dotsider.Tests/DwarfReaderTests.cs b/tests/Dotsider.Tests/DwarfReaderTests.cs index 54e76113..520e3343 100644 --- a/tests/Dotsider.Tests/DwarfReaderTests.cs +++ b/tests/Dotsider.Tests/DwarfReaderTests.cs @@ -7,6 +7,7 @@ namespace Dotsider.Tests; /// hand-built .debug_info/.debug_abbrev blobs so every form the decoder claims /// (string indirection, indexed addresses, DWARF64, references, skips) is pinned byte-for-byte. /// +[TestClass] public class DwarfReaderTests { /// Writes one abbreviation declaration (code, tag, children, attribute/form pairs). @@ -68,7 +69,8 @@ private static byte[] AddrTable(params ulong[] entries) /// high_pc, and decl file/line, and that the unit context captures the CU's base /// address and line-program offset. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadFunctions_V4_ReadsNameAddressSizeAndDeclInfo() { var abbrev = new DwarfBlob(); @@ -87,16 +89,16 @@ public void ReadFunctions_V4_ReadsNameAddressSizeAndDeclInfo() var result = DwarfReader.ReadFunctions(Sections(Cu(4, dies.ToArray()), abbrev.ToArray())); - var (function, unit) = Assert.Single(result); - Assert.Equal("main", function.Name); - Assert.Equal(0x401000UL, function.LowPc); - Assert.Equal(0x40UL, function.Size); - Assert.Equal(3, function.DeclFile); - Assert.Equal(42, function.DeclLine); - Assert.Equal(0x77, function.StmtListOffset); - Assert.Equal(4, unit.Version); - Assert.Equal(0x400000UL, unit.BaseAddress); - Assert.Equal(0x77, unit.StmtListOffset); + var (function, unit) = Assert.ContainsSingle(result); + Assert.AreEqual("main", function.Name); + Assert.AreEqual(0x401000UL, function.LowPc); + Assert.AreEqual(0x40UL, function.Size); + Assert.AreEqual(3, function.DeclFile); + Assert.AreEqual(42, function.DeclLine); + Assert.AreEqual(0x77, function.StmtListOffset); + Assert.AreEqual(4, unit.Version); + Assert.AreEqual(0x400000UL, unit.BaseAddress); + Assert.AreEqual(0x77, unit.StmtListOffset); } /// @@ -104,7 +106,8 @@ public void ReadFunctions_V4_ReadsNameAddressSizeAndDeclInfo() /// when resolving strx1/addrx1, and takes offset-class high_pc as the /// size directly. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadFunctions_V5_ExplicitBasesAndOffsetHighPc() { var abbrev = new DwarfBlob(); @@ -126,25 +129,26 @@ public void ReadFunctions_V5_ExplicitBasesAndOffsetHighPc() strOffsets: StrOffsetsTable(999, 1), addr: AddrTable(0xDEAD, 0x2000)); - var (function, unit) = Assert.Single(DwarfReader.ReadFunctions(sections)); - Assert.Equal("EntryPoint", function.Name); - Assert.Equal(0x2000UL, function.LowPc); - Assert.Equal(0x30UL, function.Size); - Assert.Equal(5, unit.Version); + var (function, unit) = Assert.ContainsSingle(DwarfReader.ReadFunctions(sections)); + Assert.AreEqual("EntryPoint", function.Name); + Assert.AreEqual(0x2000UL, function.LowPc); + Assert.AreEqual(0x30UL, function.Size); + Assert.AreEqual(5, unit.Version); } /// /// Verifies every string form resolves the name: strp, line_strp, and the five /// strx encodings through the default v5 .debug_str_offsets base. /// - [Theory(Timeout = 30_000)] - [InlineData(DwarfForm.Strp)] - [InlineData(DwarfForm.LineStrp)] - [InlineData(DwarfForm.Strx)] - [InlineData(DwarfForm.Strx1)] - [InlineData(DwarfForm.Strx2)] - [InlineData(DwarfForm.Strx3)] - [InlineData(DwarfForm.Strx4)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow(DwarfForm.Strp)] + [DataRow(DwarfForm.LineStrp)] + [DataRow(DwarfForm.Strx)] + [DataRow(DwarfForm.Strx1)] + [DataRow(DwarfForm.Strx2)] + [DataRow(DwarfForm.Strx3)] + [DataRow(DwarfForm.Strx4)] public void ReadFunctions_StringForms_ResolveName(ulong form) { var abbrev = new DwarfBlob(); @@ -170,21 +174,22 @@ public void ReadFunctions_StringForms_ResolveName(ulong form) var sections = Sections(Cu(5, dies.ToArray()), abbrev.ToArray(), str: strings, lineStr: strings, strOffsets: StrOffsetsTable(0, 1)); - var (function, _) = Assert.Single(DwarfReader.ReadFunctions(sections)); - Assert.Equal("fn", function.Name); - Assert.Equal(0x1000UL, function.LowPc); + var (function, _) = Assert.ContainsSingle(DwarfReader.ReadFunctions(sections)); + Assert.AreEqual("fn", function.Name); + Assert.AreEqual(0x1000UL, function.LowPc); } /// /// Verifies every indexed address form resolves low_pc through the default v5 /// .debug_addr base. /// - [Theory(Timeout = 30_000)] - [InlineData(DwarfForm.Addrx)] - [InlineData(DwarfForm.Addrx1)] - [InlineData(DwarfForm.Addrx2)] - [InlineData(DwarfForm.Addrx3)] - [InlineData(DwarfForm.Addrx4)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow(DwarfForm.Addrx)] + [DataRow(DwarfForm.Addrx1)] + [DataRow(DwarfForm.Addrx2)] + [DataRow(DwarfForm.Addrx3)] + [DataRow(DwarfForm.Addrx4)] public void ReadFunctions_AddressIndexForms_ResolveLowPc(ulong form) { var abbrev = new DwarfBlob(); @@ -207,15 +212,16 @@ public void ReadFunctions_AddressIndexForms_ResolveLowPc(ulong form) var sections = Sections(Cu(5, dies.ToArray()), abbrev.ToArray(), addr: AddrTable(0, 0x4000)); - var (function, _) = Assert.Single(DwarfReader.ReadFunctions(sections)); - Assert.Equal(0x4000UL, function.LowPc); + var (function, _) = Assert.ContainsSingle(DwarfReader.ReadFunctions(sections)); + Assert.AreEqual(0x4000UL, function.LowPc); } /// /// Verifies a DWARF64 unit parses: 0xFFFFFFFF-escaped length, 8-byte abbrev offset, and /// 8-byte strp section offsets. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadFunctions_Dwarf64_ParsesUnitAndWideOffsets() { var abbrev = new DwarfBlob(); @@ -233,10 +239,10 @@ public void ReadFunctions_Dwarf64_ParsesUnitAndWideOffsets() var sections = Sections(Cu(4, dies.ToArray(), is64: true), abbrev.ToArray(), str: new DwarfBlob().U8(0).CStr("fn").ToArray()); - var (function, unit) = Assert.Single(DwarfReader.ReadFunctions(sections)); - Assert.True(unit.Is64); - Assert.Equal("fn", function.Name); - Assert.Equal(0x20UL, function.Size); + var (function, unit) = Assert.ContainsSingle(DwarfReader.ReadFunctions(sections)); + Assert.IsTrue(unit.Is64); + Assert.AreEqual("fn", function.Name); + Assert.AreEqual(0x20UL, function.Size); } /// @@ -271,35 +277,38 @@ private static DwarfSections SpecificationPair(ulong refAttribute, ulong refForm /// Verifies a nameless definition resolves its name through a unit-relative /// specification reference, preferring the declaration's linkage name. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadFunctions_SpecificationRef4_ResolvesUnitRelativeName() { var sections = SpecificationPair(DwarfForm.AtSpecification, DwarfForm.Ref4, refValue: 12); - var (function, _) = Assert.Single(DwarfReader.ReadFunctions(sections)); - Assert.Equal("_ZN6Widget3RunEv", function.Name); - Assert.Equal(0x5000UL, function.LowPc); - Assert.Equal(0x10UL, function.Size); + var (function, _) = Assert.ContainsSingle(DwarfReader.ReadFunctions(sections)); + Assert.AreEqual("_ZN6Widget3RunEv", function.Name); + Assert.AreEqual(0x5000UL, function.LowPc); + Assert.AreEqual(0x10UL, function.Size); } /// /// Verifies abstract_origin in the ref_addr form resolves as a /// section-absolute offset, not unit-relative. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadFunctions_AbstractOriginRefAddr_ResolvesSectionAbsoluteName() { var sections = SpecificationPair(DwarfForm.AtAbstractOrigin, DwarfForm.RefAddr, refValue: 24); - var (function, _) = Assert.Single(DwarfReader.ReadFunctions(sections)); - Assert.Equal("_ZN6Widget3RunEv", function.Name); + var (function, _) = Assert.ContainsSingle(DwarfReader.ReadFunctions(sections)); + Assert.AreEqual("_ZN6Widget3RunEv", function.Name); } /// /// Verifies a v4 range-based subprogram (no low_pc) is kept with its /// .debug_ranges offset recorded as a section offset. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadFunctions_V4Ranges_RecordsSectionOffset() { var abbrev = new DwarfBlob(); @@ -315,18 +324,19 @@ public void ReadFunctions_V4Ranges_RecordsSectionOffset() var result = DwarfReader.ReadFunctions(Sections(Cu(4, dies.ToArray()), abbrev.ToArray())); - var (function, unit) = Assert.Single(result); - Assert.Equal("ranged", function.Name); - Assert.Equal(0x40, function.RangesOffset); - Assert.False(function.RangesIsRnglistx); - Assert.Equal(0x400000UL, unit.BaseAddress); + var (function, unit) = Assert.ContainsSingle(result); + Assert.AreEqual("ranged", function.Name); + Assert.AreEqual(0x40, function.RangesOffset); + Assert.IsFalse(function.RangesIsRnglistx); + Assert.AreEqual(0x400000UL, unit.BaseAddress); } /// /// Verifies a v5 rnglistx range reference is recorded as an index and the CU's /// explicit rnglists_base is captured. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadFunctions_V5Rnglistx_RecordsIndexAndBase() { var abbrev = new DwarfBlob(); @@ -342,10 +352,10 @@ public void ReadFunctions_V5Rnglistx_RecordsIndexAndBase() var result = DwarfReader.ReadFunctions(Sections(Cu(5, dies.ToArray()), abbrev.ToArray())); - var (function, unit) = Assert.Single(result); - Assert.Equal(2, function.RangesOffset); - Assert.True(function.RangesIsRnglistx); - Assert.Equal(0x20, unit.RnglistsBase); + var (function, unit) = Assert.ContainsSingle(result); + Assert.AreEqual(2, function.RangesOffset); + Assert.IsTrue(function.RangesIsRnglistx); + Assert.AreEqual(0x20, unit.RnglistsBase); } /// @@ -353,7 +363,8 @@ public void ReadFunctions_V5Rnglistx_RecordsIndexAndBase() /// data16, implicit_const, indirect, and an unresolvable strx2 — /// advances the cursor exactly, so the subprogram after it still parses. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadFunctions_SkipsEveryFormOnUnrelatedDie() { var abbrev = new DwarfBlob(); @@ -421,17 +432,18 @@ public void ReadFunctions_SkipsEveryFormOnUnrelatedDie() var result = DwarfReader.ReadFunctions(Sections(Cu(5, dies.ToArray()), abbrev.ToArray())); - var (function, _) = Assert.Single(result); - Assert.Equal("after", function.Name); - Assert.Equal(0x9000UL, function.LowPc); + var (function, _) = Assert.ContainsSingle(result); + Assert.AreEqual("after", function.Name); + Assert.AreEqual(0x9000UL, function.LowPc); } /// /// Verifies the linkage name (plain or MIPS-vendor) is preferred over the source name. /// - [Theory(Timeout = 30_000)] - [InlineData(DwarfForm.AtLinkageName)] - [InlineData(DwarfForm.AtMipsLinkageName)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow(DwarfForm.AtLinkageName)] + [DataRow(DwarfForm.AtMipsLinkageName)] public void ReadFunctions_LinkageName_PreferredOverSourceName(ulong linkageAttribute) { var abbrev = new DwarfBlob(); @@ -446,16 +458,16 @@ public void ReadFunctions_LinkageName_PreferredOverSourceName(ulong linkageAttri .ULeb(2).CStr("plain").CStr("_Zmangled").U64(0x1000) .ULeb(0); - var (function, _) = Assert.Single( - DwarfReader.ReadFunctions(Sections(Cu(4, dies.ToArray()), abbrev.ToArray()))); - Assert.Equal("_Zmangled", function.Name); + var (function, _) = Assert.ContainsSingle(DwarfReader.ReadFunctions(Sections(Cu(4, dies.ToArray()), abbrev.ToArray()))); + Assert.AreEqual("_Zmangled", function.Name); } /// /// Verifies an address-class high_pc below low_pc yields size 0 rather than /// wrapping negative. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadFunctions_HighPcBelowLowPc_SizeZero() { var abbrev = new DwarfBlob(); @@ -470,16 +482,16 @@ public void ReadFunctions_HighPcBelowLowPc_SizeZero() .ULeb(2).CStr("f").U64(0x2000).U64(0x1000) .ULeb(0); - var (function, _) = Assert.Single( - DwarfReader.ReadFunctions(Sections(Cu(4, dies.ToArray()), abbrev.ToArray()))); - Assert.Equal(0UL, function.Size); + var (function, _) = Assert.ContainsSingle(DwarfReader.ReadFunctions(Sections(Cu(4, dies.ToArray()), abbrev.ToArray()))); + Assert.AreEqual(0UL, function.Size); } /// /// Verifies a second unit whose declared length overruns the section ends the walk, keeping /// the first unit's functions. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadFunctions_TruncatedSecondUnit_KeepsFirstFunction() { var abbrev = new DwarfBlob(); @@ -496,15 +508,16 @@ public void ReadFunctions_TruncatedSecondUnit_KeepsFirstFunction() var result = DwarfReader.ReadFunctions(Sections([.. info], abbrev.ToArray())); - var (function, _) = Assert.Single(result); - Assert.Equal("ok", function.Name); + var (function, _) = Assert.ContainsSingle(result); + Assert.AreEqual("ok", function.Name); } /// /// Verifies an unsupported-version unit is skipped by its declared length and the walk /// continues into the next unit. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadFunctions_UnsupportedVersionUnit_SkipsToNextUnit() { var abbrev = new DwarfBlob(); @@ -520,15 +533,16 @@ public void ReadFunctions_UnsupportedVersionUnit_SkipsToNextUnit() var result = DwarfReader.ReadFunctions(Sections([.. info], abbrev.ToArray())); - var (function, _) = Assert.Single(result); - Assert.Equal("ok", function.Name); + var (function, _) = Assert.ContainsSingle(result); + Assert.AreEqual("ok", function.Name); } /// /// Verifies a v5 skeleton unit (unit type 4) contributes no functions even though it holds a /// well-formed subprogram. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadFunctions_SkeletonUnit_Skipped() { var abbrev = new DwarfBlob(); @@ -542,14 +556,15 @@ public void ReadFunctions_SkeletonUnit_Skipped() var result = DwarfReader.ReadFunctions( Sections(Cu(5, dies.ToArray(), unitType: 4), abbrev.ToArray())); - Assert.Empty(result); + Assert.IsEmpty(result); } /// /// Verifies an abbreviation code missing from the table ends that unit's walk, keeping the /// functions parsed before it. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadFunctions_UnknownAbbrevCode_KeepsEarlierFunctions() { var abbrev = new DwarfBlob(); @@ -562,31 +577,33 @@ public void ReadFunctions_UnknownAbbrevCode_KeepsEarlierFunctions() var result = DwarfReader.ReadFunctions(Sections(Cu(4, dies.ToArray()), abbrev.ToArray())); - var (function, _) = Assert.Single(result); - Assert.Equal("first", function.Name); + var (function, _) = Assert.ContainsSingle(result); + Assert.AreEqual("first", function.Name); } /// Verifies empty or absent sections yield no functions. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadFunctions_EmptyOrZeroLengthInfo_ReturnsEmpty() { - Assert.Empty(DwarfReader.ReadFunctions(Sections([], []))); + Assert.IsEmpty(DwarfReader.ReadFunctions(Sections([], []))); var zeroLength = new DwarfBlob().U32(0).Bytes(new byte[16]).ToArray(); - Assert.Empty(DwarfReader.ReadFunctions(Sections(zeroLength, [1]))); + Assert.IsEmpty(DwarfReader.ReadFunctions(Sections(zeroLength, [1]))); } /// /// Verifies maps base names through the lookup and /// tolerates absent sections as empty bytes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Collect_MapsBaseNamesAndToleratesAbsent() { var sections = DwarfSections.Collect(name => name == "info" ? [0xAB] : null); - Assert.Equal([0xAB], sections.Info); - Assert.Empty(sections.Abbrev); - Assert.False(sections.HasInfo); // DIEs without abbreviations are unreadable + Assert.AreSequenceEqual(new byte[] { 0xAB }, sections.Info); + Assert.IsEmpty(sections.Abbrev); + Assert.IsFalse(sections.HasInfo); // DIEs without abbreviations are unreadable } } diff --git a/tests/Dotsider.Tests/DynamicAnalysisAccessibilityTests.cs b/tests/Dotsider.Tests/DynamicAnalysisAccessibilityTests.cs index 811197c9..930054ae 100644 --- a/tests/Dotsider.Tests/DynamicAnalysisAccessibilityTests.cs +++ b/tests/Dotsider.Tests/DynamicAnalysisAccessibilityTests.cs @@ -6,23 +6,24 @@ namespace Dotsider.Tests; /// /// Tests for Dynamic Analysis Accessibility. /// +[TestClass] public class DynamicAnalysisAccessibilityTests { /// /// Verifies category colors all categories have entries. /// - [Fact] + [TestMethod] public void CategoryColors_AllCategoriesHaveEntries() { foreach (var category in Enum.GetValues()) - Assert.True(DynamicAnalysisView.CategoryColors.ContainsKey(category), + Assert.IsTrue(DynamicAnalysisView.CategoryColors.ContainsKey(category), $"Missing color for {category}"); } /// /// Verifies category colors no duplicate colors. /// - [Fact] + [TestMethod] public void CategoryColors_NoDuplicateColors() { var seen = new Dictionary<(byte R, byte G, byte B), TraceEventCategory>(); @@ -34,7 +35,7 @@ public void CategoryColors_NoDuplicateColors() continue; var key = (color.R, color.G, color.B); - Assert.False(seen.TryGetValue(key, out var existing), + Assert.IsFalse(seen.TryGetValue(key, out var existing), $"{category} shares color ({color.R},{color.G},{color.B}) with {existing}"); seen[key] = category; } @@ -43,12 +44,12 @@ public void CategoryColors_NoDuplicateColors() /// /// Verifies socket and http have different colors. /// - [Fact] + [TestMethod] public void SocketAndHttp_HaveDifferentColors() { var httpColor = DynamicAnalysisView.CategoryColors[TraceEventCategory.Http]; var socketColor = DynamicAnalysisView.CategoryColors[TraceEventCategory.Socket]; - Assert.False( + Assert.IsFalse( httpColor.R == socketColor.R && httpColor.G == socketColor.G && httpColor.B == socketColor.B, "Http and Socket must have distinct colors for accessibility"); } @@ -58,36 +59,36 @@ public void SocketAndHttp_HaveDifferentColors() /// /// Verifies try parse jit detail valid format returns true with components. /// - [Theory] - [InlineData("System.String.Concat", "System.String", "Concat")] - [InlineData("MyApp.Services.UserService.GetUser", "MyApp.Services.UserService", "GetUser")] - [InlineData("GlobalType.Run", "GlobalType", "Run")] + [TestMethod] + [DataRow("System.String.Concat", "System.String", "Concat")] + [DataRow("MyApp.Services.UserService.GetUser", "MyApp.Services.UserService", "GetUser")] + [DataRow("GlobalType.Run", "GlobalType", "Run")] public void TryParseJitDetail_ValidFormat_ReturnsTrueWithComponents( string detail, string expectedType, string expectedMethod) { - Assert.True(DynamicAnalysisView.TryParseJitDetail(detail, out var declaringType, out var methodName)); - Assert.Equal(expectedType, declaringType); - Assert.Equal(expectedMethod, methodName); + Assert.IsTrue(DynamicAnalysisView.TryParseJitDetail(detail, out var declaringType, out var methodName)); + Assert.AreEqual(expectedType, declaringType); + Assert.AreEqual(expectedMethod, methodName); } /// /// Verifies try parse jit detail invalid format returns false. /// - [Theory] - [InlineData("")] - [InlineData("NoDotHere")] - [InlineData(".LeadingDot")] + [TestMethod] + [DataRow("")] + [DataRow("NoDotHere")] + [DataRow(".LeadingDot")] public void TryParseJitDetail_InvalidFormat_ReturnsFalse(string detail) { - Assert.False(DynamicAnalysisView.TryParseJitDetail(detail, out _, out _)); + Assert.IsFalse(DynamicAnalysisView.TryParseJitDetail(detail, out _, out _)); } /// /// Verifies try parse jit detail trailing dot returns false. /// - [Fact] + [TestMethod] public void TryParseJitDetail_TrailingDot_ReturnsFalse() { - Assert.False(DynamicAnalysisView.TryParseJitDetail("System.String.", out _, out _)); + Assert.IsFalse(DynamicAnalysisView.TryParseJitDetail("System.String.", out _, out _)); } } diff --git a/tests/Dotsider.Tests/DynamicFocusColorTests.cs b/tests/Dotsider.Tests/DynamicFocusColorTests.cs index f13ac0b8..52072afb 100644 --- a/tests/Dotsider.Tests/DynamicFocusColorTests.cs +++ b/tests/Dotsider.Tests/DynamicFocusColorTests.cs @@ -12,9 +12,11 @@ namespace Dotsider.Tests; /// Reproduces #92: the focused row on the Dynamic tab's Events table shows /// category-colored text (yellow, green, etc.) instead of black on teal. /// -[Collection("SampleAssemblies")] -public class DynamicFocusColorTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class DynamicFocusColorTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _hex1bApp; @@ -32,7 +34,7 @@ public class DynamicFocusColorTests(SampleAssemblyFixture samples) : IDisposable _hex1bApp = new Hex1bApp( ctx => { - _state ??= new DotsiderState(_hex1bApp!, samples.HelloWorldDll); + _state ??= new DotsiderState(_hex1bApp!, Samples.HelloWorldDll); dotsiderApp ??= new DotsiderApp(_state); return Task.FromResult(dotsiderApp.Build(ctx)); }, @@ -47,11 +49,12 @@ public class DynamicFocusColorTests(SampleAssemblyFixture samples) : IDisposable /// /// Verifies events focused row category cell uses black foreground. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Events_FocusedRow_CategoryCellUsesBlackForeground() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -105,7 +108,7 @@ await auto.WaitUntilAsync(s => }, description: "teal focus background to appear"); var focusedKey = _state!.DynamicEventsFocusedKey; - Assert.NotNull(focusedKey); + Assert.IsNotNull(focusedKey); // The focused row's category cell must NOT have any of the known category // colors. When focused, it should be black (0,0,0) or null (theme default). @@ -116,14 +119,13 @@ await auto.WaitUntilAsync(s => if (categoryFg is not null) { var fg = categoryFg.Value; - Assert.False(knownCategoryColors.Contains((fg.R, fg.G, fg.B)), + Assert.DoesNotContain((fg.R, fg.G, fg.B), knownCategoryColors, $"Focused row category cell still has category color ({fg.R},{fg.G},{fg.B}) — should be black on focus"); // WCAG 2.1 AA requires >= 4.5:1 contrast ratio for normal text. // Verify the foreground color against the teal focus background. var ratio = ContrastRatio(fg.R, fg.G, fg.B, teal.R, teal.G, teal.B); - Assert.True(ratio >= 4.5, - $"Focused category cell fails WCAG AA: contrast ratio {ratio:F2}:1 " + + Assert.IsGreaterThanOrEqualTo(4.5, ratio, $"Focused category cell fails WCAG AA: contrast ratio {ratio:F2}:1 " + $"(fg={fg.R},{fg.G},{fg.B} bg={teal.R},{teal.G},{teal.B}), minimum is 4.5:1"); } @@ -159,10 +161,10 @@ private static double Linearize(double c) => /// public void Dispose() { + GC.SuppressFinalize(this); _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/DynamicTabGuardTests.cs b/tests/Dotsider.Tests/DynamicTabGuardTests.cs index fb21f3bb..0992aafc 100644 --- a/tests/Dotsider.Tests/DynamicTabGuardTests.cs +++ b/tests/Dotsider.Tests/DynamicTabGuardTests.cs @@ -9,9 +9,11 @@ namespace Dotsider.Tests; /// /// Tests for Dynamic Tab Guard. /// -[Collection("SampleAssemblies")] -public class DynamicTabGuardTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class DynamicTabGuardTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _hex1bApp; @@ -60,10 +62,10 @@ private Hex1bApp CreateApp() /// public void Dispose() { + GC.SuppressFinalize(this); _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); - GC.SuppressFinalize(this); } // --- Unit tests --- @@ -71,14 +73,15 @@ public void Dispose() /// /// Verifies is net framework true for net fx console. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void IsNetFramework_TrueForNetFxConsole() { - Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "net48 sample is Windows-only"); + TestSkip.Unless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "net48 sample is Windows-only"); var app = CreateApp(); - using var state = new DotsiderState(app, samples.NetFxConsoleExe!); - Assert.True(state.IsNetFramework); - Assert.Contains(".NETFramework", state.Analyzer.TargetFramework); + using var state = new DotsiderState(app, Samples.NetFxConsoleExe!); + Assert.IsTrue(state.IsNetFramework); + Assert.Contains(".NETFramework", state.Analyzer.TargetFramework!); } /// @@ -88,16 +91,17 @@ public void IsNetFramework_TrueForNetFxConsole() /// inferred state — otherwise EventPipe tracing would be wrongly enabled and the General tab /// would show "(unknown)". /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void IsNetFramework_TrueForClr2RootWithoutTfa_AndDisplayShowsInferredLabel() { - Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "net35 sample is Windows-only"); - Assert.NotNull(samples.NetFxBindingRedirectsClr2Exe); + TestSkip.Unless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "net35 sample is Windows-only"); + Assert.IsNotNull(Samples.NetFxBindingRedirectsClr2Exe); var app = CreateApp(); - using var state = new DotsiderState(app, samples.NetFxBindingRedirectsClr2Exe!); + using var state = new DotsiderState(app, Samples.NetFxBindingRedirectsClr2Exe!); - Assert.True(state.IsNetFramework); - Assert.Null(state.Analyzer.TargetFramework); // no TFA on the assembly + Assert.IsTrue(state.IsNetFramework); + Assert.IsNull(state.Analyzer.TargetFramework); // no TFA on the assembly var display = state.EffectiveTargetFrameworkDisplay; Assert.Contains("CLR v2.0", display); Assert.Contains("inferred", display, StringComparison.OrdinalIgnoreCase); @@ -106,27 +110,29 @@ public void IsNetFramework_TrueForClr2RootWithoutTfa_AndDisplayShowsInferredLabe /// /// Verifies is net framework false for core apps. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void IsNetFramework_FalseForCoreApps() { var app = CreateApp(); - string[] paths = [samples.HelloWorldDll, samples.RichLibraryDll, samples.ComplexAppDll]; + string[] paths = [Samples.HelloWorldDll, Samples.RichLibraryDll, Samples.ComplexAppDll]; foreach (var path in paths) { using var state = new DotsiderState(app, path); - Assert.False(state.IsNetFramework, $"IsNetFramework should be false for {Path.GetFileName(path)}"); + Assert.IsFalse(state.IsNetFramework, $"IsNetFramework should be false for {Path.GetFileName(path)}"); } } /// /// Verifies is native aot true for native aot console. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void IsNativeAot_TrueForNativeAotConsole() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.NativeAotConsoleExe!); - Assert.True(state.IsNativeAot); + using var state = new DotsiderState(app, Samples.NativeAotConsoleExe!); + Assert.IsTrue(state.IsNativeAot); } // --- Input sequence tests --- @@ -134,12 +140,13 @@ public void IsNativeAot_TrueForNativeAotConsole() /// /// Verifies tab8 net framework shows guard message. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab8_NetFramework_ShowsGuardMessage() { - Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "net48 sample is Windows-only"); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.NetFxConsoleExe!); + TestSkip.Unless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "net48 sample is Windows-only"); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.NetFxConsoleExe!); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -157,7 +164,7 @@ public async Task Tab8_NetFramework_ShowsGuardMessage() .ApplyAsync(terminal, cts.Token); // Verify tracer was NOT created - Assert.Null(_state!.Tracer); + Assert.IsNull(_state!.Tracer); cts.Cancel(); await runTask; @@ -166,11 +173,12 @@ public async Task Tab8_NetFramework_ShowsGuardMessage() /// /// Verifies tab8 native aot shows idle view not guard. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab8_NativeAot_ShowsIdleViewNotGuard() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.NativeAotConsoleExe!); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.NativeAotConsoleExe!); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -199,11 +207,12 @@ public async Task Tab8_NativeAot_ShowsIdleViewNotGuard() /// /// Verifies native aot navigate all tabs no crash. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task NativeAot_NavigateAllTabs_NoCrash() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.NativeAotConsoleExe!); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.NativeAotConsoleExe!); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -234,7 +243,7 @@ public async Task NativeAot_NavigateAllTabs_NoCrash() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(TabId.Strings, _state!.CurrentTab); + Assert.AreEqual(TabId.Strings, _state!.CurrentTab); cts.Cancel(); await runTask; diff --git a/tests/Dotsider.Tests/DynamicYankIntegrationTests.cs b/tests/Dotsider.Tests/DynamicYankIntegrationTests.cs index 1932aa5b..fc16dc8f 100644 --- a/tests/Dotsider.Tests/DynamicYankIntegrationTests.cs +++ b/tests/Dotsider.Tests/DynamicYankIntegrationTests.cs @@ -10,9 +10,11 @@ namespace Dotsider.Tests; /// Integration tests for yank flash, readonly editor yank, and focus management /// on the Dynamic tab (issue #103). /// -[Collection("SampleAssemblies")] -public class DynamicYankIntegrationTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class DynamicYankIntegrationTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private ClipboardCapturingWorkloadAdapter? _clipboardAdapter; private Hex1bTerminal? _terminal; @@ -22,7 +24,7 @@ public class DynamicYankIntegrationTests(SampleAssemblyFixture samples) : IDispo private (Hex1bTerminal terminal, Hex1bApp app, CancellationToken ct) Launch(string dllPath) { - _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + _cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); _workload = new Hex1bAppWorkloadAdapter(); _clipboardAdapter = new ClipboardCapturingWorkloadAdapter(_workload); _terminal = Hex1bTerminal.CreateBuilder() @@ -84,22 +86,22 @@ private Hex1bTerminalInputSequenceBuilder NavigateToCounters() .Key(Hex1bKey.RightArrow) .WaitUntil(_ => _state!.DynamicSubTab == DynamicSubTabId.Counters, TimeSpan.FromSeconds(5)) .WaitUntil(_ => _state!.DynamicCpuEditorState is not null, TimeSpan.FromSeconds(5)) - .WaitUntil(_ => IsFocusedOnEditor(), TimeSpan.FromSeconds(5)); + .WaitUntil(_ => IsFocusedOnEditor(_state!.DynamicCpuEditorState), TimeSpan.FromSeconds(5)); } /// Moves focus out of the Counters editors to the subtab strip. private async Task TabOutOfCountersEditorsAsync(Hex1bTerminal terminal, CancellationToken ct) { - // During a live trace, render cycles can be slow because each frame - // processes EventPipe events. Instead of tabbing through editors one - // at a time (which requires one render cycle per Tab), directly request - // focus on the subtab strip and wait for it. - _state!.App.RequestFocus(node => - node.GetType().Name.StartsWith("TabPanelNode")); - _state.App.Invalidate(); - await new Hex1bTerminalInputSequenceBuilder() - .WaitUntil(_ => !IsFocusedOnEditor(), TimeSpan.FromSeconds(10)) + .WaitUntil(_ => IsFocusedOnEditor(_state!.DynamicCpuEditorState), TimeSpan.FromSeconds(5)) + .Key(Hex1bKey.Tab) + .WaitUntil(_ => IsFocusedOnEditor(_state!.DynamicMemoryEditorState), TimeSpan.FromSeconds(5)) + .Key(Hex1bKey.Tab) + .WaitUntil(_ => IsFocusedOnEditor(_state!.DynamicGcEditorState), TimeSpan.FromSeconds(5)) + .Key(Hex1bKey.Tab) + .WaitUntil(_ => IsFocusedOnEditor(_state!.DynamicThreadingEditorState), TimeSpan.FromSeconds(5)) + .Key(Hex1bKey.Tab) + .WaitUntil(_ => !IsFocusedOnEditor(), TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); } @@ -119,10 +121,11 @@ private Hex1bTerminalInputSequenceBuilder NavigateFromCountersToSummary() /// /// Verifies dynamic events yank on focused row copies payload and flashes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Dynamic_Events_YankOnFocusedRow_CopiesPayload_AndFlashes() { - var (terminal, app, ct) = Launch(samples.HelloWorldDll); + var (terminal, app, ct) = Launch(Samples.HelloWorldDll); var runTask = app.RunAsync(ct); await LaunchTraceAndWaitForExit() @@ -134,8 +137,8 @@ await LaunchTraceAndWaitForExit() .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(_state!.YankNotification); - Assert.True(_clipboardAdapter!.ClipboardWrites.TryDequeue(out _), + Assert.IsNotNull(_state!.YankNotification); + Assert.IsTrue(_clipboardAdapter!.ClipboardWrites.TryDequeue(out _), "CopyToClipboard should have emitted an OSC 52 sequence"); _cts!.Cancel(); @@ -145,10 +148,11 @@ await LaunchTraceAndWaitForExit() /// /// Verifies dynamic output yank on focused row copies payload and flashes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Dynamic_Output_YankOnFocusedRow_CopiesPayload_AndFlashes() { - var (terminal, app, ct) = Launch(samples.HelloWorldDll); + var (terminal, app, ct) = Launch(Samples.HelloWorldDll); var runTask = app.RunAsync(ct); await LaunchTraceAndWaitForExit().Build().ApplyAsync(terminal, ct); @@ -168,8 +172,8 @@ public async Task Dynamic_Output_YankOnFocusedRow_CopiesPayload_AndFlashes() .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(_state!.YankNotification); - Assert.True(_clipboardAdapter!.ClipboardWrites.TryDequeue(out _), + Assert.IsNotNull(_state!.YankNotification); + Assert.IsTrue(_clipboardAdapter!.ClipboardWrites.TryDequeue(out _), "CopyToClipboard should have emitted an OSC 52 sequence"); _cts!.Cancel(); @@ -179,10 +183,11 @@ public async Task Dynamic_Output_YankOnFocusedRow_CopiesPayload_AndFlashes() /// /// Verifies dynamic events yank flash during search. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Dynamic_Events_YankFlashDuringSearch() { - var (terminal, app, ct) = Launch(samples.HelloWorldDll); + var (terminal, app, ct) = Launch(Samples.HelloWorldDll); var runTask = app.RunAsync(ct); await LaunchTraceAndWaitForExit() @@ -201,8 +206,8 @@ await LaunchTraceAndWaitForExit() .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(_state!.YankNotification); - Assert.True(_clipboardAdapter!.ClipboardWrites.TryDequeue(out _), + Assert.IsNotNull(_state!.YankNotification); + Assert.IsTrue(_clipboardAdapter!.ClipboardWrites.TryDequeue(out _), "CopyToClipboard should have emitted an OSC 52 sequence"); _cts!.Cancel(); @@ -212,10 +217,11 @@ await LaunchTraceAndWaitForExit() /// /// Verifies dynamic counters selection yank works. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Dynamic_Counters_SelectionYank_Works() { - var (terminal, app, ct) = Launch(samples.HelloWorldDll); + var (terminal, app, ct) = Launch(Samples.HelloWorldDll); var runTask = app.RunAsync(ct); await LaunchTraceAndWaitForExit().Build().ApplyAsync(terminal, ct); @@ -226,8 +232,8 @@ await NavigateToCounters() .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(_state!.YankNotification); - Assert.True(_clipboardAdapter!.ClipboardWrites.TryDequeue(out _), + Assert.IsNotNull(_state!.YankNotification); + Assert.IsTrue(_clipboardAdapter!.ClipboardWrites.TryDequeue(out _), "CopyToClipboard should have emitted an OSC 52 sequence"); _cts!.Cancel(); @@ -237,10 +243,11 @@ await NavigateToCounters() /// /// Verifies dynamic summary selection yank works. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Dynamic_Summary_SelectionYank_Works() { - var (terminal, app, ct) = Launch(samples.HelloWorldDll); + var (terminal, app, ct) = Launch(Samples.HelloWorldDll); var runTask = app.RunAsync(ct); await LaunchTraceAndWaitForExit().Build().ApplyAsync(terminal, ct); @@ -253,8 +260,8 @@ await NavigateFromCountersToSummary() .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(_state!.YankNotification); - Assert.True(_clipboardAdapter!.ClipboardWrites.TryDequeue(out _), + Assert.IsNotNull(_state!.YankNotification); + Assert.IsTrue(_clipboardAdapter!.ClipboardWrites.TryDequeue(out _), "CopyToClipboard should have emitted an OSC 52 sequence"); _cts!.Cancel(); @@ -264,16 +271,17 @@ await NavigateFromCountersToSummary() /// /// Verifies dynamic left right navigate sub tabs from editor focus. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Dynamic_LeftRightNavigateSubTabsFromEditorFocus() { - var (terminal, app, ct) = Launch(samples.HelloWorldDll); + var (terminal, app, ct) = Launch(Samples.HelloWorldDll); var runTask = app.RunAsync(ct); await LaunchTraceAndWaitForExit().Build().ApplyAsync(terminal, ct); await NavigateToCounters().Build().ApplyAsync(terminal, ct); - Assert.Equal(DynamicSubTabId.Counters, _state!.DynamicSubTab); + Assert.AreEqual(DynamicSubTabId.Counters, _state!.DynamicSubTab); // Left from Counters → Events await new Hex1bTerminalInputSequenceBuilder() @@ -282,7 +290,7 @@ public async Task Dynamic_LeftRightNavigateSubTabsFromEditorFocus() .Build() .ApplyAsync(terminal, ct); - Assert.Equal(DynamicSubTabId.Events, _state.DynamicSubTab); + Assert.AreEqual(DynamicSubTabId.Events, _state.DynamicSubTab); // Right from Events → Counters await new Hex1bTerminalInputSequenceBuilder() @@ -291,7 +299,7 @@ public async Task Dynamic_LeftRightNavigateSubTabsFromEditorFocus() .Build() .ApplyAsync(terminal, ct); - Assert.Equal(DynamicSubTabId.Counters, _state.DynamicSubTab); + Assert.AreEqual(DynamicSubTabId.Counters, _state.DynamicSubTab); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -300,18 +308,19 @@ public async Task Dynamic_LeftRightNavigateSubTabsFromEditorFocus() /// /// Verifies dynamic tab from editor focuses subtab strip stays on sub tab. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Dynamic_TabFromEditor_FocusesSubtabStrip_StaysOnSubTab() { - var (terminal, app, ct) = Launch(samples.HelloWorldDll); + var (terminal, app, ct) = Launch(Samples.HelloWorldDll); var runTask = app.RunAsync(ct); await LaunchTraceAndWaitForExit().Build().ApplyAsync(terminal, ct); await NavigateToCounters().Build().ApplyAsync(terminal, ct); await TabOutOfCountersEditorsAsync(terminal, ct); - Assert.Equal(TabId.Dynamic, _state!.CurrentTab); - Assert.Equal(DynamicSubTabId.Counters, _state.DynamicSubTab); + Assert.AreEqual(TabId.Dynamic, _state!.CurrentTab); + Assert.AreEqual(DynamicSubTabId.Counters, _state.DynamicSubTab); await new Hex1bTerminalInputSequenceBuilder() .Key(Hex1bKey.RightArrow) @@ -319,7 +328,7 @@ public async Task Dynamic_TabFromEditor_FocusesSubtabStrip_StaysOnSubTab() .Build() .ApplyAsync(terminal, ct); - Assert.Equal(TabId.Dynamic, _state.CurrentTab); + Assert.AreEqual(TabId.Dynamic, _state.CurrentTab); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -328,10 +337,11 @@ public async Task Dynamic_TabFromEditor_FocusesSubtabStrip_StaysOnSubTab() /// /// Verifies dynamic rerun clears dynamic editor caches. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Dynamic_Rerun_ClearsDynamicEditorCaches() { - var (terminal, app, ct) = Launch(samples.HelloWorldDll); + var (terminal, app, ct) = Launch(Samples.HelloWorldDll); var runTask = app.RunAsync(ct); await LaunchTraceAndWaitForExit().Build().ApplyAsync(terminal, ct); @@ -339,8 +349,8 @@ public async Task Dynamic_Rerun_ClearsDynamicEditorCaches() await TabOutOfCountersEditorsAsync(terminal, ct); await NavigateFromCountersToSummary().Build().ApplyAsync(terminal, ct); - Assert.NotNull(_state!.DynamicCpuEditorState); - Assert.NotNull(_state.DynamicSummaryEditorState); + Assert.IsNotNull(_state!.DynamicCpuEditorState); + Assert.IsNotNull(_state.DynamicSummaryEditorState); await new Hex1bTerminalInputSequenceBuilder() .Key(Hex1bKey.Tab) @@ -356,10 +366,10 @@ _state.DynamicCpuEditorState is null .Build() .ApplyAsync(terminal, ct); - Assert.Null(_state.DynamicCpuEditorState); - Assert.Null(_state.DynamicCpuEditorText); - Assert.Null(_state.DynamicSummaryEditorState); - Assert.Null(_state.DynamicSummaryEditorText); + Assert.IsNull(_state.DynamicCpuEditorState); + Assert.IsNull(_state.DynamicCpuEditorText); + Assert.IsNull(_state.DynamicSummaryEditorState); + Assert.IsNull(_state.DynamicSummaryEditorText); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -368,10 +378,11 @@ _state.DynamicCpuEditorState is null /// /// Verifies dynamic counters live update preserves selection while focused. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Dynamic_Counters_LiveUpdate_PreservesSelectionWhileFocused() { - var (terminal, app, ct) = Launch(samples.MinimalApiDll); + var (terminal, app, ct) = Launch(Samples.MinimalApiDll); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -395,7 +406,7 @@ public async Task Dynamic_Counters_LiveUpdate_PreservesSelectionWhileFocused() .ApplyAsync(terminal, ct); await Task.Delay(1500, ct); - Assert.Same(editorStateBefore, _state.DynamicCpuEditorState); + Assert.AreSame(editorStateBefore, _state.DynamicCpuEditorState); _state.Tracer?.Stop(); _cts!.Cancel(); @@ -405,10 +416,11 @@ public async Task Dynamic_Counters_LiveUpdate_PreservesSelectionWhileFocused() /// /// Verifies dynamic summary live update preserves selection while focused. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Dynamic_Summary_LiveUpdate_PreservesSelectionWhileFocused() { - var (terminal, app, ct) = Launch(samples.MinimalApiDll); + var (terminal, app, ct) = Launch(Samples.MinimalApiDll); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -437,7 +449,7 @@ await NavigateFromCountersToSummary() .ApplyAsync(terminal, ct); await Task.Delay(1500, ct); - Assert.Same(editorStateBefore, _state.DynamicSummaryEditorState); + Assert.AreSame(editorStateBefore, _state.DynamicSummaryEditorState); _state.Tracer?.Stop(); _cts!.Cancel(); @@ -447,10 +459,11 @@ await NavigateFromCountersToSummary() /// /// Verifies dynamic summary post exit refresh updates while focused. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Dynamic_Summary_PostExitRefresh_UpdatesWhileFocused() { - var (terminal, app, ct) = Launch(samples.MinimalApiDll); + var (terminal, app, ct) = Launch(Samples.MinimalApiDll); var runTask = app.RunAsync(ct); // Launch trace, wait for running, switch to Summary (via Counters) @@ -473,7 +486,7 @@ await NavigateFromCountersToSummary() .Build() .ApplyAsync(terminal, ct); - Assert.True(IsFocusedOnEditor()); + Assert.IsTrue(IsFocusedOnEditor()); // Capture the frozen EditorState reference while the process is running. // The freeze mechanism keeps this exact instance alive while focused+running. @@ -494,7 +507,7 @@ await NavigateFromCountersToSummary() await auto.WaitUntilAsync(_ => _state.DynamicSummaryEditorState != frozenState, description: "editor state to update after freeze lifts"); - Assert.NotSame(frozenState, _state.DynamicSummaryEditorState); + Assert.AreNotSame(frozenState, _state.DynamicSummaryEditorState); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -505,6 +518,7 @@ await auto.WaitUntilAsync(_ => _state.DynamicSummaryEditorState != frozenState, /// public void Dispose() { + GC.SuppressFinalize(this); _state?.Tracer?.Stop(); _state?.Dispose(); _hex1bApp?.Dispose(); @@ -512,6 +526,5 @@ public void Dispose() _clipboardAdapter?.Dispose(); _workload?.Dispose(); _cts?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/EhFrameReaderTests.cs b/tests/Dotsider.Tests/EhFrameReaderTests.cs index 39d8e6c2..fba66854 100644 --- a/tests/Dotsider.Tests/EhFrameReaderTests.cs +++ b/tests/Dotsider.Tests/EhFrameReaderTests.cs @@ -7,6 +7,7 @@ namespace Dotsider.Tests; /// .eh_frame blobs covering the pointer-encoding matrix (absolute, PC-relative, fixed, /// LEB128, signed), CIE augmentation shapes, and the terminator and damage behaviors. /// +[TestClass] public class EhFrameReaderTests { private static byte[] Entry(DwarfBlob content) => @@ -59,7 +60,8 @@ private static byte[] Image(ulong ehFrameAddress, params byte[][] entries) /// /// Verifies absolute 64-bit pointers decode and the boundary maps to its containing section. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadBoundaries_Absptr_DecodesAndMapsSection() { var cie = Cie(0x00); @@ -67,20 +69,21 @@ public void ReadBoundaries_Absptr_DecodesAndMapsSection() var boundaries = EhFrameReader.ReadBoundaries(Image(0x500000, cie, fde)); - var b = Assert.Single(boundaries); - Assert.Equal("sub_401010", b.Name); - Assert.Equal(0x401010UL, b.VirtualAddress); - Assert.Equal(0x40, b.Size); - Assert.Equal(".text", b.Section); - Assert.True(b.IsBoundary); - Assert.NotNull(b.FileOffset); + var b = Assert.ContainsSingle(boundaries); + Assert.AreEqual("sub_401010", b.Name); + Assert.AreEqual(0x401010UL, b.VirtualAddress); + Assert.AreEqual(0x40, b.Size); + Assert.AreEqual(".text", b.Section); + Assert.IsTrue(b.IsBoundary); + Assert.IsNotNull(b.FileOffset); } /// /// Verifies the common pcrel|sdata4 encoding: the location is relative to the /// pointer field's own address, and the range is a plain length. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadBoundaries_PcrelSdata4_ResolvesAgainstFieldAddress() { const ulong ehFrameAddress = 0x500000; @@ -97,49 +100,52 @@ public void ReadBoundaries_PcrelSdata4_ResolvesAgainstFieldAddress() var boundaries = EhFrameReader.ReadBoundaries(Image(ehFrameAddress, cie, fde1, fde2)); - Assert.Equal(2, boundaries.Count); - Assert.Equal(0x401010UL, boundaries[0].VirtualAddress); - Assert.Equal(0x40, boundaries[0].Size); - Assert.Equal(0x401080UL, boundaries[1].VirtualAddress); - Assert.Equal(0x10, boundaries[1].Size); + Assert.HasCount(2, boundaries); + Assert.AreEqual(0x401010UL, boundaries[0].VirtualAddress); + Assert.AreEqual(0x40, boundaries[0].Size); + Assert.AreEqual(0x401080UL, boundaries[1].VirtualAddress); + Assert.AreEqual(0x10, boundaries[1].Size); } /// Verifies the fixed udata4 and variable ULEB128 formats decode absolutely. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadBoundaries_Udata4AndUleb_Decode() { var cie4 = Cie(0x03); // udata4 absolute var fde4 = Fde((uint)(cie4.Length + 4), new DwarfBlob().U32(0x401020).U32(0x20)); - var one = Assert.Single(EhFrameReader.ReadBoundaries(Image(0x500000, cie4, fde4))); - Assert.Equal(0x401020UL, one.VirtualAddress); - Assert.Equal(0x20, one.Size); + var one = Assert.ContainsSingle(EhFrameReader.ReadBoundaries(Image(0x500000, cie4, fde4))); + Assert.AreEqual(0x401020UL, one.VirtualAddress); + Assert.AreEqual(0x20, one.Size); var cieLeb = Cie(0x01); // uleb128 absolute var fdeLeb = Fde((uint)(cieLeb.Length + 4), new DwarfBlob().ULeb(0x401030).ULeb(0x30)); - var leb = Assert.Single(EhFrameReader.ReadBoundaries(Image(0x500000, cieLeb, fdeLeb))); - Assert.Equal(0x401030UL, leb.VirtualAddress); - Assert.Equal(0x30, leb.Size); + var leb = Assert.ContainsSingle(EhFrameReader.ReadBoundaries(Image(0x500000, cieLeb, fdeLeb))); + Assert.AreEqual(0x401030UL, leb.VirtualAddress); + Assert.AreEqual(0x30, leb.Size); } /// /// Verifies a zPLR augmentation with a personality pointer parses the encoding behind /// it, on a version-3 CIE whose return register is a ULEB. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadBoundaries_ZplrVersion3_ReadsEncodingBehindPersonality() { var cie = Cie(0x03, augmentation: "zPLR", version: 3); var fde = Fde((uint)(cie.Length + 4), new DwarfBlob().U32(0x401040).U32(0x18)); - var b = Assert.Single(EhFrameReader.ReadBoundaries(Image(0x500000, cie, fde))); - Assert.Equal(0x401040UL, b.VirtualAddress); - Assert.Equal(0x18, b.Size); + var b = Assert.ContainsSingle(EhFrameReader.ReadBoundaries(Image(0x500000, cie, fde))); + Assert.AreEqual(0x401040UL, b.VirtualAddress); + Assert.AreEqual(0x18, b.Size); } /// /// Verifies the zero-length terminator stops the walk, and entries after it are ignored. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadBoundaries_Terminator_StopsWalk() { var cie = Cie(0x00); @@ -152,21 +158,22 @@ public void ReadBoundaries_Terminator_StopsWalk() var image = SyntheticImageBuilders.BuildElf((".eh_frame", 0x500000, section.ToArray())); - Assert.Single(EhFrameReader.ReadBoundaries(image)); + Assert.ContainsSingle(EhFrameReader.ReadBoundaries(image)); } /// /// Verifies defective entries are skipped, not misread: a zero location, an FDE naming an /// unknown CIE, and a truncated tail after a good FDE. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadBoundaries_DefectiveEntries_SkippedOrKeptPartial() { var cie = Cie(0x00); var zeroLocation = Fde((uint)(cie.Length + 4), new DwarfBlob().U64(0).U64(0x40)); var orphan = Fde(2, new DwarfBlob().U64(0x401050).U64(0x40)); // points into no CIE - Assert.Empty(EhFrameReader.ReadBoundaries(Image(0x500000, cie, zeroLocation))); - Assert.Empty(EhFrameReader.ReadBoundaries(Image(0x500000, orphan))); + Assert.IsEmpty(EhFrameReader.ReadBoundaries(Image(0x500000, cie, zeroLocation))); + Assert.IsEmpty(EhFrameReader.ReadBoundaries(Image(0x500000, orphan))); var good = Fde((uint)(cie.Length + 4), new DwarfBlob().U64(0x401010).U64(0x40)); var truncated = new DwarfBlob().U32(0xFFF0).U32(0).ToArray(); // claims more than present @@ -176,9 +183,9 @@ public void ReadBoundaries_DefectiveEntries_SkippedOrKeptPartial() section.AddRange(truncated); var image = SyntheticImageBuilders.BuildElf((".eh_frame", 0x500000, section.ToArray())); - Assert.Single(EhFrameReader.ReadBoundaries(image)); + Assert.ContainsSingle(EhFrameReader.ReadBoundaries(image)); - Assert.Empty(EhFrameReader.ReadBoundaries( + Assert.IsEmpty(EhFrameReader.ReadBoundaries( SyntheticImageBuilders.BuildElf((".text", 0x401000, new byte[8])))); } } diff --git a/tests/Dotsider.Tests/ElfSectionTests.cs b/tests/Dotsider.Tests/ElfSectionTests.cs index 3f4997d9..02d3240a 100644 --- a/tests/Dotsider.Tests/ElfSectionTests.cs +++ b/tests/Dotsider.Tests/ElfSectionTests.cs @@ -7,13 +7,15 @@ namespace Dotsider.Tests; /// — the section-header walk that hands DWARF, /// symbol-table, and build-id readers their bytes — driven with synthetic ELF images. /// +[TestClass] public class ElfSectionTests { /// /// Verifies the walk returns every named section with its address, file offset, and size, /// alongside the null section and .shstrtab the builder always emits. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadSections_ReturnsNamedSectionsWithLocation() { var image = SyntheticImageBuilders.BuildElf( @@ -22,36 +24,38 @@ public void ReadSections_ReturnsNamedSectionsWithLocation() var sections = ElfImageReader.ReadSections(image); - Assert.Equal(4, sections.Count); // null + .text + .debug_info + .shstrtab - var text = Assert.Single(sections, s => s.Name == ".text"); - Assert.Equal(0x401000UL, text.Address); - Assert.Equal(4, text.Size); - Assert.Equal(1, image[text.FileOffset]); + Assert.HasCount(4, sections); // null + .text + .debug_info + .shstrtab + var text = Assert.ContainsSingle(s => s.Name == ".text", sections); + Assert.AreEqual(0x401000UL, text.Address); + Assert.AreEqual(4, text.Size); + Assert.AreEqual(1, image[text.FileOffset]); - var info = Assert.Single(sections, s => s.Name == ".debug_info"); - Assert.Equal(2, info.Size); - Assert.Equal(9, image[info.FileOffset]); + var info = Assert.ContainsSingle(s => s.Name == ".debug_info", sections); + Assert.AreEqual(2, info.Size); + Assert.AreEqual(9, image[info.FileOffset]); } /// Verifies the name lookup finds a present section and reports an absent one. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TryGetSection_FindsPresentAndReportsAbsent() { var image = SyntheticImageBuilders.BuildElf((".debug_str", 0, new byte[] { 0, (byte)'a', 0 })); - Assert.True(ElfImageReader.TryGetSection(image, ".debug_str", out var section)); - Assert.Equal(3, section.Size); - Assert.False(ElfImageReader.TryGetSection(image, ".debug_line", out _)); + Assert.IsTrue(ElfImageReader.TryGetSection(image, ".debug_str", out var section)); + Assert.AreEqual(3, section.Size); + Assert.IsFalse(ElfImageReader.TryGetSection(image, ".debug_line", out _)); } /// Verifies non-ELF bytes and truncated images yield no sections rather than throwing. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadSections_RejectsNonElfAndTruncated() { - Assert.Empty(ElfImageReader.ReadSections([0x4D, 0x5A, 0, 0])); + Assert.IsEmpty(ElfImageReader.ReadSections([0x4D, 0x5A, 0, 0])); var truncated = SyntheticImageBuilders.BuildElf((".text", 0, new byte[] { 1 }))[..70]; - Assert.Empty(ElfImageReader.ReadSections(truncated)); + Assert.IsEmpty(ElfImageReader.ReadSections(truncated)); } /// @@ -59,7 +63,8 @@ public void ReadSections_RejectsNonElfAndTruncated() /// inflates to the original bytes, plain sections pass through, and unsupported or /// malformed compression reads as absent rather than as garbage. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadSectionBytes_InflatesCompressedSections() { byte[] payload = [.. Enumerable.Range(0, 512).Select(i => (byte)(i * 7))]; @@ -69,21 +74,21 @@ public void ReadSectionBytes_InflatesCompressedSections() var sections = ElfImageReader.ReadSections(image); var compressed = sections.Single(s => s.Name == ".debug_info"); - Assert.Equal(payload, ElfImageReader.ReadSectionBytes(image, compressed)); + Assert.AreSequenceEqual(payload, ElfImageReader.ReadSectionBytes(image, compressed)); var plain = sections.Single(s => s.Name == ".debug_abbrev"); - Assert.Equal(payload, ElfImageReader.ReadSectionBytes(image, plain)); + Assert.AreSequenceEqual(payload, ElfImageReader.ReadSectionBytes(image, plain)); // Unsupported compression type (zstd = 2): absent, not misread. var zstd = SyntheticImageBuilders.CompressDebugSection(payload); zstd[0] = 2; var zstdImage = SyntheticImageBuilders.BuildElf((".debug_info", 0, zstd, 1u, 0u, 0x800UL)); - Assert.Null(ElfImageReader.ReadSectionBytes( + Assert.IsNull(ElfImageReader.ReadSectionBytes( zstdImage, ElfImageReader.ReadSections(zstdImage).Single(s => s.Name == ".debug_info"))); // Truncated header: absent. var tiny = SyntheticImageBuilders.BuildElf((".debug_info", 0, new byte[8], 1u, 0u, 0x800UL)); - Assert.Null(ElfImageReader.ReadSectionBytes( + Assert.IsNull(ElfImageReader.ReadSectionBytes( tiny, ElfImageReader.ReadSections(tiny).Single(s => s.Name == ".debug_info"))); } } diff --git a/tests/Dotsider.Tests/ElfSidecarIdentityTests.cs b/tests/Dotsider.Tests/ElfSidecarIdentityTests.cs index b42ca56f..c0aa7401 100644 --- a/tests/Dotsider.Tests/ElfSidecarIdentityTests.cs +++ b/tests/Dotsider.Tests/ElfSidecarIdentityTests.cs @@ -7,6 +7,7 @@ namespace Dotsider.Tests; /// all-present-signals-must-match rule that decides whether a .dbg sidecar belongs to a /// stripped image. /// +[TestClass] public class ElfSidecarIdentityTests { private static readonly byte[] IdA = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04]; @@ -22,68 +23,74 @@ private static byte[] SidecarWithBuildId(byte[] id) => (".debug_info", 0, new byte[] { 1 })); /// Verifies the standard CRC-32 check value. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Crc32_KnownAnswer() { - Assert.Equal(0xCBF43926U, Crc32.Compute("123456789"u8)); - Assert.Equal(0U, Crc32.Compute([])); + Assert.AreEqual(0xCBF43926U, Crc32.Compute("123456789"u8)); + Assert.AreEqual(0U, Crc32.Compute([])); } /// Verifies the build-id note parses, including walking past a foreign note first. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TryReadBuildId_ParsesGnuNoteBehindForeignNote() { var image = SyntheticImageBuilders.BuildElf( (".note.gnu.build-id", 0, SyntheticImageBuilders.GnuBuildIdNote(IdA, precedeWithForeignNote: true))); - Assert.True(ElfImageReader.TryReadBuildId(image, out var id)); - Assert.Equal(IdA, id); + Assert.IsTrue(ElfImageReader.TryReadBuildId(image, out var id)); + Assert.AreSequenceEqual(IdA, id); var plain = SyntheticImageBuilders.BuildElf((".text", 0, new byte[] { 1 })); - Assert.False(ElfImageReader.TryReadBuildId(plain, out _)); + Assert.IsFalse(ElfImageReader.TryReadBuildId(plain, out _)); var malformed = SyntheticImageBuilders.BuildElf((".note.gnu.build-id", 0, new byte[] { 1, 2, 3 })); - Assert.False(ElfImageReader.TryReadBuildId(malformed, out _)); + Assert.IsFalse(ElfImageReader.TryReadBuildId(malformed, out _)); } /// Verifies the debuglink reader returns the file name and the 4-aligned CRC. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TryReadDebugLink_ParsesNameAndAlignedCrc() { // "myapp.dbg" + NUL = 10 bytes -> CRC 4-aligned at offset 12. var image = SyntheticImageBuilders.BuildElf( (".gnu_debuglink", 0, SyntheticImageBuilders.GnuDebugLink("myapp.dbg", 0x1234_5678))); - Assert.True(ElfImageReader.TryReadDebugLink(image, out var name, out var crc)); - Assert.Equal("myapp.dbg", name); - Assert.Equal(0x1234_5678U, crc); + Assert.IsTrue(ElfImageReader.TryReadDebugLink(image, out var name, out var crc)); + Assert.AreEqual("myapp.dbg", name); + Assert.AreEqual(0x1234_5678U, crc); var absent = SyntheticImageBuilders.BuildElf((".text", 0, new byte[] { 1 })); - Assert.False(ElfImageReader.TryReadDebugLink(absent, out _, out _)); + Assert.IsFalse(ElfImageReader.TryReadDebugLink(absent, out _, out _)); } /// Verifies matching build ids accept the sidecar. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Check_BuildIdMatch_Accepts() { - Assert.Equal(ElfSidecarMatch.Matched, + Assert.AreEqual(ElfSidecarMatch.Matched, ElfSidecarIdentity.Check(ImageWithBuildId(IdA), SidecarWithBuildId(IdA))); } /// Verifies a differing or absent sidecar build id rejects the sidecar. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Check_BuildIdMismatchOrAbsent_Rejects() { - Assert.Equal(ElfSidecarMatch.Mismatched, + Assert.AreEqual(ElfSidecarMatch.Mismatched, ElfSidecarIdentity.Check(ImageWithBuildId(IdA), SidecarWithBuildId(IdB))); var noIdSidecar = SyntheticImageBuilders.BuildElf((".debug_info", 0, new byte[] { 1 })); - Assert.Equal(ElfSidecarMatch.Mismatched, + Assert.AreEqual(ElfSidecarMatch.Mismatched, ElfSidecarIdentity.Check(ImageWithBuildId(IdA), noIdSidecar)); } /// Verifies the debuglink CRC alone can accept, and a CRC failure rejects. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Check_DebugLinkCrc_AcceptsOnMatchRejectsOnFailure() { var sidecar = SyntheticImageBuilders.BuildElf((".debug_info", 0, new byte[] { 1 })); @@ -91,18 +98,19 @@ public void Check_DebugLinkCrc_AcceptsOnMatchRejectsOnFailure() var matching = SyntheticImageBuilders.BuildElf( (".gnu_debuglink", 0, SyntheticImageBuilders.GnuDebugLink("app.dbg", crc))); - Assert.Equal(ElfSidecarMatch.Matched, ElfSidecarIdentity.Check(matching, sidecar)); + Assert.AreEqual(ElfSidecarMatch.Matched, ElfSidecarIdentity.Check(matching, sidecar)); var failing = SyntheticImageBuilders.BuildElf( (".gnu_debuglink", 0, SyntheticImageBuilders.GnuDebugLink("app.dbg", crc ^ 1))); - Assert.Equal(ElfSidecarMatch.Mismatched, ElfSidecarIdentity.Check(failing, sidecar)); + Assert.AreEqual(ElfSidecarMatch.Mismatched, ElfSidecarIdentity.Check(failing, sidecar)); } /// /// Verifies every present signal must pass: one failing signal rejects even when the other /// matches, in both directions. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Check_BothSignals_OneFailureRejects() { var sidecar = SidecarWithBuildId(IdA); @@ -112,28 +120,29 @@ static byte[] Image(byte[] id, uint expectedCrc) => SyntheticImageBuilders.Build (".note.gnu.build-id", 0, SyntheticImageBuilders.GnuBuildIdNote(id)), (".gnu_debuglink", 0, SyntheticImageBuilders.GnuDebugLink("app.dbg", expectedCrc))); - Assert.Equal(ElfSidecarMatch.Matched, ElfSidecarIdentity.Check(Image(IdA, crc), sidecar)); - Assert.Equal(ElfSidecarMatch.Mismatched, ElfSidecarIdentity.Check(Image(IdA, crc ^ 1), sidecar)); - Assert.Equal(ElfSidecarMatch.Mismatched, ElfSidecarIdentity.Check(Image(IdB, crc), sidecar)); + Assert.AreEqual(ElfSidecarMatch.Matched, ElfSidecarIdentity.Check(Image(IdA, crc), sidecar)); + Assert.AreEqual(ElfSidecarMatch.Mismatched, ElfSidecarIdentity.Check(Image(IdA, crc ^ 1), sidecar)); + Assert.AreEqual(ElfSidecarMatch.Mismatched, ElfSidecarIdentity.Check(Image(IdB, crc), sidecar)); } /// /// Verifies a signal-free image accepts only loosely — same machine and real debug info — /// and rejects when the machine differs or the sidecar lacks .debug_info. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Check_NoSignals_LooseChecksDecide() { var image = SyntheticImageBuilders.BuildElf((".text", 0, new byte[] { 1 })); var sidecar = SyntheticImageBuilders.BuildElf((".debug_info", 0, new byte[] { 1 })); - Assert.Equal(ElfSidecarMatch.LooseMatch, ElfSidecarIdentity.Check(image, sidecar)); + Assert.AreEqual(ElfSidecarMatch.LooseMatch, ElfSidecarIdentity.Check(image, sidecar)); var foreignMachine = SyntheticImageBuilders.BuildElf((".debug_info", 0, new byte[] { 1 })); foreignMachine[18] = 0x28; // EM_ARM - Assert.Equal(ElfSidecarMatch.Mismatched, ElfSidecarIdentity.Check(image, foreignMachine)); + Assert.AreEqual(ElfSidecarMatch.Mismatched, ElfSidecarIdentity.Check(image, foreignMachine)); var noDebugInfo = SyntheticImageBuilders.BuildElf((".text", 0, new byte[] { 1 })); - Assert.Equal(ElfSidecarMatch.Mismatched, ElfSidecarIdentity.Check(image, noDebugInfo)); + Assert.AreEqual(ElfSidecarMatch.Mismatched, ElfSidecarIdentity.Check(image, noDebugInfo)); } } diff --git a/tests/Dotsider.Tests/ElfSymtabReaderTests.cs b/tests/Dotsider.Tests/ElfSymtabReaderTests.cs index eb6f1a82..04a3a881 100644 --- a/tests/Dotsider.Tests/ElfSymtabReaderTests.cs +++ b/tests/Dotsider.Tests/ElfSymtabReaderTests.cs @@ -8,6 +8,7 @@ namespace Dotsider.Tests; /// STT_OBJECT entries surface with their exact st_size while functions, undefined, /// unnamed, and reserved-section entries are skipped. /// +[TestClass] public class ElfSymtabReaderTests { private static byte[] Sym(uint nameOffset, byte type, ushort shndx, ulong value, ulong size) @@ -32,7 +33,8 @@ private static byte[] Concat(params byte[][] parts) /// Verifies the object pass: an STT_OBJECT keeps its exact size and maps to its /// section, while STT_FUNC, undefined, unnamed, and absolute entries are skipped. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadDataSymbols_KeepsNamedObjectsWithExactSizes() { var strtab = "\0_ZTV6Widget\0main\0"u8.ToArray(); @@ -52,28 +54,29 @@ public void ReadDataSymbols_KeepsNamedObjectsWithExactSizes() var symbols = ElfSymtabReader.ReadDataSymbols(image, ElfImageReader.ReadSections(image)); - var s = Assert.Single(symbols); - Assert.Equal("_ZTV6Widget", s.Name); - Assert.Equal(0x2010UL, s.VirtualAddress); - Assert.Equal(0x18, s.Size); // exact st_size, not nearest-next - Assert.Equal(".data", s.Section); - Assert.True(s.IsData); - Assert.NotNull(s.FileOffset); + var s = Assert.ContainsSingle(symbols); + Assert.AreEqual("_ZTV6Widget", s.Name); + Assert.AreEqual(0x2010UL, s.VirtualAddress); + Assert.AreEqual(0x18, s.Size); // exact st_size, not nearest-next + Assert.AreEqual(".data", s.Section); + Assert.IsTrue(s.IsData); + Assert.IsNotNull(s.FileOffset); } /// Verifies malformed tables yield nothing rather than throwing. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadDataSymbols_Malformed_ReturnsEmpty() { // No symbol table at all. var plain = SyntheticImageBuilders.BuildElf((".text", 0x1000, new byte[8])); - Assert.Empty(ElfSymtabReader.ReadDataSymbols(plain, ElfImageReader.ReadSections(plain))); + Assert.IsEmpty(ElfSymtabReader.ReadDataSymbols(plain, ElfImageReader.ReadSections(plain))); // Link points past the section table. var symtab = Concat(Sym(0, 0, 0, 0, 0), Sym(1, 1, 1, 0x1000, 8)); var badLink = SyntheticImageBuilders.BuildElf( (".text", 0x1000, new byte[8], 1u, 0u), (".symtab", 0, symtab, 2u, 99u)); - Assert.Empty(ElfSymtabReader.ReadDataSymbols(badLink, ElfImageReader.ReadSections(badLink))); + Assert.IsEmpty(ElfSymtabReader.ReadDataSymbols(badLink, ElfImageReader.ReadSections(badLink))); } } diff --git a/tests/Dotsider.Tests/EscapeTimeoutPresentationAdapterTests.cs b/tests/Dotsider.Tests/EscapeTimeoutPresentationAdapterTests.cs index a2177875..21c132d6 100644 --- a/tests/Dotsider.Tests/EscapeTimeoutPresentationAdapterTests.cs +++ b/tests/Dotsider.Tests/EscapeTimeoutPresentationAdapterTests.cs @@ -10,9 +10,11 @@ namespace Dotsider.Tests; /// /// Tests for Escape Timeout Presentation Adapter. /// -[Collection("SampleAssemblies")] -public class EscapeTimeoutPresentationAdapterTests(SampleAssemblyFixture samples) +[TestClass] +public class EscapeTimeoutPresentationAdapterTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + // ======================================== // Test infrastructure // ======================================== @@ -113,10 +115,11 @@ public ValueTask DisposeAsync() /// /// Verifies standalone escape produces escape event. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task StandaloneEscape_ProducesEscapeEvent() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var queued = new QueuedPresentationAdapter(); var escAdapter = new EscapeTimeoutPresentationAdapter(queued, TimeSpan.FromMilliseconds(50)); var events = new ConcurrentQueue(); @@ -154,10 +157,11 @@ await TestHelpers.WaitUntilAsync( /// /// Verifies split escape sequence within timeout produces up arrow. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SplitEscapeSequence_WithinTimeout_ProducesUpArrow() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var queued = new QueuedPresentationAdapter(); var escAdapter = new EscapeTimeoutPresentationAdapter(queued, TimeSpan.FromMilliseconds(50)); var events = new ConcurrentQueue(); @@ -198,10 +202,11 @@ await TestHelpers.WaitUntilAsync( /// /// Verifies complete escape sequence single read produces up arrow. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task CompleteEscapeSequence_SingleRead_ProducesUpArrow() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var queued = new QueuedPresentationAdapter(); var escAdapter = new EscapeTimeoutPresentationAdapter(queued, TimeSpan.FromMilliseconds(50)); var events = new ConcurrentQueue(); @@ -241,10 +246,11 @@ await TestHelpers.WaitUntilAsync( /// /// Verifies coalesced prefix and esc i before escape. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task CoalescedPrefixAndEsc_IBeforeEscape() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var queued = new QueuedPresentationAdapter(); var escAdapter = new EscapeTimeoutPresentationAdapter(queued, TimeSpan.FromMilliseconds(50)); var events = new ConcurrentQueue(); @@ -282,16 +288,17 @@ await TestHelpers.WaitUntilAsync( var eventList = events.ToArray(); var iIndex = Array.IndexOf(eventList, "i"); var escIndex = Array.IndexOf(eventList, "escape"); - Assert.True(iIndex < escIndex, $"Expected 'i' before 'escape', got i={iIndex} esc={escIndex}"); + Assert.IsLessThan(escIndex, iIndex, $"Expected 'i' before 'escape', got i={iIndex} esc={escIndex}"); } /// /// Verifies standalone esc then literal bracket not combined into csi. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task StandaloneEsc_ThenLiteralBracket_NotCombinedIntoCsi() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var queued = new QueuedPresentationAdapter(); var escAdapter = new EscapeTimeoutPresentationAdapter(queued, TimeSpan.FromMilliseconds(50)); var events = new ConcurrentQueue(); @@ -340,10 +347,11 @@ await TestHelpers.WaitUntilAsync( /// /// Verifies standalone escape followed by mouse event produces escape event. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task StandaloneEscape_FollowedByMouseEvent_ProducesEscapeEvent() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var queued = new QueuedPresentationAdapter(); var escAdapter = new EscapeTimeoutPresentationAdapter(queued, TimeSpan.FromMilliseconds(50)); var events = new ConcurrentQueue(); @@ -388,11 +396,11 @@ await TestHelpers.WaitUntilAsync( var escIndex = Array.IndexOf(eventList, "escape"); var mouseIndex = Array.IndexOf(eventList, "mouse-click"); var upIndex = Array.IndexOf(eventList, "up"); - Assert.True(escIndex >= 0, "Expected escape event"); - Assert.True(mouseIndex >= 0, "Expected mouse-click event (mouse packet not silently dropped)"); - Assert.True(upIndex >= 0, "Expected up event"); - Assert.True(escIndex < mouseIndex, $"Expected escape before mouse-click, got esc={escIndex} mouse={mouseIndex}"); - Assert.True(mouseIndex < upIndex, $"Expected mouse-click before up, got mouse={mouseIndex} up={upIndex}"); + Assert.IsGreaterThanOrEqualTo(0, escIndex, "Expected escape event"); + Assert.IsGreaterThanOrEqualTo(0, mouseIndex, "Expected mouse-click event (mouse packet not silently dropped)"); + Assert.IsGreaterThanOrEqualTo(0, upIndex, "Expected up event"); + Assert.IsLessThan(mouseIndex, escIndex, $"Expected escape before mouse-click, got esc={escIndex} mouse={mouseIndex}"); + Assert.IsLessThan(upIndex, mouseIndex, $"Expected mouse-click before up, got mouse={mouseIndex} up={upIndex}"); } /// @@ -415,10 +423,11 @@ await TestHelpers.WaitUntilAsync( /// mouse event therefore proves the overlay is active. /// /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task WithMouse_EnablesMouseCursorOverlay() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var queued = new QueuedPresentationAdapter(); var escAdapter = new EscapeTimeoutPresentationAdapter(queued, TimeSpan.FromMilliseconds(50)); @@ -449,7 +458,7 @@ await TestHelpers.WaitUntilAsync( () => queued.OutputContains(cursorShow), TimeSpan.FromSeconds(10)); - Assert.True(queued.OutputContains(cursorShow), + Assert.IsTrue(queued.OutputContains(cursorShow), "Expected cursor-show sequence (\\x1b[?25h) from mouse cursor overlay"); } @@ -459,10 +468,11 @@ await TestHelpers.WaitUntilAsync( /// continuation bytes are processed as literal characters. This is inherent to /// timeout-based escape detection (same tradeoff in xterm, vim, tmux, neovim). /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task LateSplitSequence_KnownLimitation_DegradedBehavior() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var queued = new QueuedPresentationAdapter(); var escAdapter = new EscapeTimeoutPresentationAdapter(queued, TimeSpan.FromMilliseconds(50)); var events = new ConcurrentQueue(); @@ -512,10 +522,11 @@ await TestHelpers.WaitUntilAsync( /// /// Verifies raw escape exits hex insert mode. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task RawEscape_ExitsHexInsertMode() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var queued = new QueuedPresentationAdapter(); var escAdapter = new EscapeTimeoutPresentationAdapter(queued, TimeSpan.FromMilliseconds(50)); DotsiderState? state = null; @@ -530,7 +541,7 @@ public async Task RawEscape_ExitsHexInsertMode() options.EnableMouse = true; }, app => ctx => { - state ??= new DotsiderState(app, samples.HelloWorldDll); + state ??= new DotsiderState(app, Samples.HelloWorldDll); dotsiderApp ??= new DotsiderApp(state); return dotsiderApp.Build(ctx); }) @@ -550,8 +561,8 @@ public async Task RawEscape_ExitsHexInsertMode() .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(state); - Assert.Equal(HexEditMode.Insert, state!.HexMode); + Assert.IsNotNull(state); + Assert.AreEqual(HexEditMode.Insert, state!.HexMode); // Send raw ESC through the adapter path queued.EnqueueInput(0x1b); @@ -561,17 +572,18 @@ await TestHelpers.WaitUntilAsync( () => state.HexMode == HexEditMode.Normal, TimeSpan.FromSeconds(10)); - Assert.Equal(HexEditMode.Normal, state.HexMode); - Assert.True(state.HexEditorState.IsReadOnly); + Assert.AreEqual(HexEditMode.Normal, state.HexMode); + Assert.IsTrue(state.HexEditorState.IsReadOnly); } /// /// Verifies raw escape dismisses editing search. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task RawEscape_DismissesEditingSearch() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var queued = new QueuedPresentationAdapter(); var escAdapter = new EscapeTimeoutPresentationAdapter(queued, TimeSpan.FromMilliseconds(50)); DotsiderState? state = null; @@ -586,7 +598,7 @@ public async Task RawEscape_DismissesEditingSearch() options.EnableMouse = true; }, app => ctx => { - state ??= new DotsiderState(app, samples.HelloWorldDll); + state ??= new DotsiderState(app, Samples.HelloWorldDll); dotsiderApp ??= new DotsiderApp(state); return dotsiderApp.Build(ctx); }) @@ -603,7 +615,7 @@ public async Task RawEscape_DismissesEditingSearch() .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(state); + Assert.IsNotNull(state); await TestHelpers.WaitUntilAsync( () => state!.Search[state.CurrentTab].IsActive && !state.Search[state.CurrentTab].IsConfirmed, TimeSpan.FromSeconds(10)); @@ -616,16 +628,17 @@ await TestHelpers.WaitUntilAsync( () => !state!.Search[state.CurrentTab].IsActive, TimeSpan.FromSeconds(10)); - Assert.False(state.Search[state.CurrentTab].IsActive); + Assert.IsFalse(state.Search[state.CurrentTab].IsActive); } /// /// Verifies raw escape dismisses confirmed search. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task RawEscape_DismissesConfirmedSearch() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var queued = new QueuedPresentationAdapter(); var escAdapter = new EscapeTimeoutPresentationAdapter(queued, TimeSpan.FromMilliseconds(50)); DotsiderState? state = null; @@ -640,7 +653,7 @@ public async Task RawEscape_DismissesConfirmedSearch() options.EnableMouse = true; }, app => ctx => { - state ??= new DotsiderState(app, samples.HelloWorldDll); + state ??= new DotsiderState(app, Samples.HelloWorldDll); dotsiderApp ??= new DotsiderApp(state); return dotsiderApp.Build(ctx); }) @@ -671,7 +684,7 @@ await TestHelpers.WaitUntilAsync( .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(state); + Assert.IsNotNull(state); await TestHelpers.WaitUntilAsync( () => state!.Search[state.CurrentTab].IsConfirmed, TimeSpan.FromSeconds(10)); @@ -684,6 +697,6 @@ await TestHelpers.WaitUntilAsync( () => !state!.Search[state.CurrentTab].IsActive, TimeSpan.FromSeconds(10)); - Assert.False(state!.Search[state.CurrentTab].IsActive); + Assert.IsFalse(state!.Search[state.CurrentTab].IsActive); } } diff --git a/tests/Dotsider.Tests/FrozenObjectReaderTests.cs b/tests/Dotsider.Tests/FrozenObjectReaderTests.cs index c4a3a658..ced3996e 100644 --- a/tests/Dotsider.Tests/FrozenObjectReaderTests.cs +++ b/tests/Dotsider.Tests/FrozenObjectReaderTests.cs @@ -7,20 +7,23 @@ namespace Dotsider.Tests; /// Tests for frozen string recovery from the Native AOT frozen object region, exercised /// through . /// -[Collection("SampleAssemblies")] -public class FrozenObjectReaderTests(SampleAssemblyFixture samples) +[TestClass] +public class FrozenObjectReaderTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// Verifies frozen strings are recovered from the sample and include the literal it /// prints. On Windows and macOS the region is file-backed; on Linux it is rebuilt from /// the dehydrated data — either way the literals are recovered. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FrozenStrings_NativeAotExe_RecoversLiterals() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var frozen = analyzer.FrozenStrings; @@ -31,34 +34,36 @@ public void FrozenStrings_NativeAotExe_RecoversLiterals() Assert.Fail($"frozen={frozen.Count}; sections=[{sections}]"); } - Assert.All(frozen, s => Assert.Equal(StringSource.FrozenObject, s.Source)); + TestAssert.All(frozen, s => Assert.AreEqual(StringSource.FrozenObject, s.Source)); } /// /// Verifies recovered frozen strings carry offsets inside the file. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FrozenStrings_NativeAotExe_HaveValidOffsets() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var size = new FileInfo(samples.NativeAotConsoleExe!).Length; - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + var size = new FileInfo(Samples.NativeAotConsoleExe!).Length; + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var frozen = analyzer.FrozenStrings; - Assert.NotEmpty(frozen); - Assert.All(frozen, s => Assert.InRange(s.Offset, 0, (int)size - 1)); + Assert.IsNotEmpty(frozen); + TestAssert.All(frozen, s => Assert.IsInRange(0, (int)size - 1, s.Offset)); } /// /// Verifies a managed assembly has no frozen strings. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FrozenStrings_ManagedDll_Empty() { - using var analyzer = new AssemblyAnalyzer(samples.RichLibraryDll); + using var analyzer = new AssemblyAnalyzer(Samples.RichLibraryDll); - Assert.Empty(analyzer.FrozenStrings); + Assert.IsEmpty(analyzer.FrozenStrings); } } diff --git a/tests/Dotsider.Tests/HexRowDocumentTests.cs b/tests/Dotsider.Tests/HexRowDocumentTests.cs index df4ef2bc..c68c1aa0 100644 --- a/tests/Dotsider.Tests/HexRowDocumentTests.cs +++ b/tests/Dotsider.Tests/HexRowDocumentTests.cs @@ -5,12 +5,14 @@ namespace Dotsider.Tests; /// /// Tests for Hex Row Document. /// +[TestClass] public class HexRowDocumentTests { /// /// Verifies get line text binary content does not throw. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetLineText_BinaryContent_DoesNotThrow() { var bytes = new byte[256]; @@ -25,7 +27,8 @@ public void GetLineText_BinaryContent_DoesNotThrow() /// /// Verifies get line length binary content does not throw. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetLineLength_BinaryContent_DoesNotThrow() { var bytes = new byte[256]; @@ -40,7 +43,8 @@ public void GetLineLength_BinaryContent_DoesNotThrow() /// /// Verifies get line text last row binary content. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetLineText_LastRow_BinaryContent() { var bytes = new byte[256]; @@ -51,13 +55,14 @@ public void GetLineText_LastRow_BinaryContent() // Last row is the worst case for byte-to-char misalignment var lastRow = hexDoc.LineCount; var text = hexDoc.GetLineText(lastRow); - Assert.NotNull(text); + Assert.IsNotNull(text); } /// /// Verifies get line length consistent with get line text. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetLineLength_ConsistentWithGetLineText() { var bytes = new byte[256]; @@ -69,79 +74,84 @@ public void GetLineLength_ConsistentWithGetLineText() { var text = hexDoc.GetLineText(line); var length = hexDoc.GetLineLength(line); - Assert.Equal(text.Length, length); + Assert.AreEqual(text.Length, length); } } /// /// Verifies get line text empty document returns empty. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetLineText_EmptyDocument_ReturnsEmpty() { var doc = new Hex1bDocument([]); var hexDoc = new HexRowDocument(doc) { BytesPerRow = 16 }; - Assert.Equal(1, hexDoc.LineCount); - Assert.Equal("", hexDoc.GetLineText(1)); + Assert.AreEqual(1, hexDoc.LineCount); + Assert.AreEqual("", hexDoc.GetLineText(1)); } /// /// Verifies get line text single byte. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetLineText_SingleByte() { var doc = new Hex1bDocument([0x42]); var hexDoc = new HexRowDocument(doc) { BytesPerRow = 16 }; - Assert.Equal(1, hexDoc.LineCount); + Assert.AreEqual(1, hexDoc.LineCount); var text = hexDoc.GetLineText(1); - Assert.NotEmpty(text); + Assert.IsNotEmpty(text); } /// /// Verifies line count matches ceil division. /// - [Theory(Timeout = 30_000)] - [InlineData(1, 1)] - [InlineData(15, 1)] - [InlineData(16, 1)] - [InlineData(17, 2)] - [InlineData(255, 16)] - [InlineData(256, 16)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow(1, 1)] + [DataRow(15, 1)] + [DataRow(16, 1)] + [DataRow(17, 2)] + [DataRow(255, 16)] + [DataRow(256, 16)] public void LineCount_MatchesCeilDivision(int byteCount, int expectedLines) { var bytes = new byte[byteCount]; var doc = new Hex1bDocument(bytes); var hexDoc = new HexRowDocument(doc) { BytesPerRow = 16 }; - Assert.Equal(expectedLines, hexDoc.LineCount); + Assert.AreEqual(expectedLines, hexDoc.LineCount); } /// /// Verifies get line text out of range throws. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetLineText_OutOfRange_Throws() { var doc = new Hex1bDocument(new byte[32]); var hexDoc = new HexRowDocument(doc) { BytesPerRow = 16 }; - Assert.Throws(() => hexDoc.GetLineText(0)); - Assert.Throws(() => hexDoc.GetLineText(hexDoc.LineCount + 1)); + Assert.ThrowsExactly(() => hexDoc.GetLineText(0)); + Assert.ThrowsExactly(() => hexDoc.GetLineText(hexDoc.LineCount + 1)); } /// /// Verifies get line length out of range throws. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetLineLength_OutOfRange_Throws() { var doc = new Hex1bDocument(new byte[32]); var hexDoc = new HexRowDocument(doc) { BytesPerRow = 16 }; - Assert.Throws(() => hexDoc.GetLineLength(0)); - Assert.Throws(() => hexDoc.GetLineLength(hexDoc.LineCount + 1)); + Assert.ThrowsExactly(() => hexDoc.GetLineLength(0)); + Assert.ThrowsExactly(() => hexDoc.GetLineLength(hexDoc.LineCount + 1)); } } diff --git a/tests/Dotsider.Tests/HexSaveStressTests.cs b/tests/Dotsider.Tests/HexSaveStressTests.cs index 70867b99..5e9d0123 100644 --- a/tests/Dotsider.Tests/HexSaveStressTests.cs +++ b/tests/Dotsider.Tests/HexSaveStressTests.cs @@ -10,9 +10,11 @@ namespace Dotsider.Tests; /// /// Tests for Hex Save Stress. /// -[Collection("SampleAssemblies")] -public class HexSaveStressTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class HexSaveStressTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _hex1bApp; @@ -61,19 +63,20 @@ private static Hex1bTerminalInputSequenceBuilder EditSafeByte(Hex1bTerminalInput /// /// Verifies locked file falls back to tmp path. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task LockedFile_FallsBackToTmpPath() { var tempDir = Path.Combine(Path.GetTempPath(), $"dotsider-test-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); var tempDll = Path.Combine(tempDir, "HelloWorld.dll"); - File.Copy(samples.HelloWorldDll, tempDll); + File.Copy(Samples.HelloWorldDll, tempDll); FileStream? fileLock = null; try { var (terminal, app) = CreateDotsiderApp(tempDll); - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); @@ -110,9 +113,9 @@ await EditSafeByte(new Hex1bTerminalInputSequenceBuilder()) .Build() .ApplyAsync(terminal, ct); - Assert.Contains("could not overwrite original", _state!.HexNotification); - Assert.True(File.Exists(tempDll + ".tmp"), ".tmp file should remain after fallback"); - Assert.False(_state.HexIsDirty); + Assert.Contains("could not overwrite original", _state!.HexNotification!); + Assert.IsTrue(File.Exists(tempDll + ".tmp"), ".tmp file should remain after fallback"); + Assert.IsFalse(_state.HexIsDirty); await runTask.ContinueWith(_ => { }, ct); } @@ -135,18 +138,19 @@ await EditSafeByte(new Hex1bTerminalInputSequenceBuilder()) /// /// Verifies invalid edit rejects corrupted pe. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task InvalidEdit_RejectsCorruptedPe() { var tempDir = Path.Combine(Path.GetTempPath(), $"dotsider-test-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); var tempDll = Path.Combine(tempDir, "HelloWorld.dll"); - File.Copy(samples.HelloWorldDll, tempDll); + File.Copy(Samples.HelloWorldDll, tempDll); try { var (terminal, app) = CreateDotsiderApp(tempDll); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -169,11 +173,11 @@ public async Task InvalidEdit_RejectsCorruptedPe() .ApplyAsync(terminal, cts.Token); await Task.Delay(100, cts.Token); - Assert.Contains("invalid image", _state!.HexNotification); - Assert.False(File.Exists(tempDll + ".tmp"), ".tmp should be cleaned up after validation failure"); - Assert.True(_state.HexIsDirty, "Document should still be dirty after failed save"); + Assert.Contains("invalid image", _state!.HexNotification!); + Assert.IsFalse(File.Exists(tempDll + ".tmp"), ".tmp should be cleaned up after validation failure"); + Assert.IsTrue(_state.HexIsDirty, "Document should still be dirty after failed save"); // Analyzer should still be functional (never disposed during Phase 1 failure) - Assert.Equal(tempDll, _state.Analyzer.FilePath); + Assert.AreEqual(tempDll, _state.Analyzer.FilePath); cts.Cancel(); await runTask; @@ -187,18 +191,19 @@ public async Task InvalidEdit_RejectsCorruptedPe() /// /// Verifies double save no dirty state after first. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task DoubleSave_NoDirtyStateAfterFirst() { var tempDir = Path.Combine(Path.GetTempPath(), $"dotsider-test-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); var tempDll = Path.Combine(tempDir, "HelloWorld.dll"); - File.Copy(samples.HelloWorldDll, tempDll); + File.Copy(Samples.HelloWorldDll, tempDll); try { var (terminal, app) = CreateDotsiderApp(tempDll); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -213,9 +218,9 @@ await EditSafeByte(new Hex1bTerminalInputSequenceBuilder()) .ApplyAsync(terminal, cts.Token); await Task.Delay(100, cts.Token); - Assert.False(_state!.HexIsDirty, "Should not be dirty after save"); - Assert.Equal(HexEditMode.Normal, _state.HexMode); - Assert.Contains("written", _state.HexNotification); + Assert.IsFalse(_state!.HexIsDirty, "Should not be dirty after save"); + Assert.AreEqual(HexEditMode.Normal, _state.HexMode); + Assert.Contains("written", _state.HexNotification!); // Send another Ctrl+S — the binding guard (HexIsDirty) prevents it. // Use E (toggle endianness) as a sentinel: if the app processes E, @@ -231,8 +236,8 @@ await EditSafeByte(new Hex1bTerminalInputSequenceBuilder()) .ApplyAsync(terminal, cts.Token); await Task.Delay(100, cts.Token); - Assert.Null(_state.HexNotification); - Assert.False(File.Exists(tempDll + ".tmp"), "No .tmp should be created on non-dirty save"); + Assert.IsNull(_state.HexNotification); + Assert.IsFalse(File.Exists(tempDll + ".tmp"), "No .tmp should be created on non-dirty save"); cts.Cancel(); await runTask; @@ -246,7 +251,8 @@ await EditSafeByte(new Hex1bTerminalInputSequenceBuilder()) /// /// Verifies native binary hex save succeeds. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task NativeBinary_HexSave_Succeeds() { // Copy ONLY the apphost (no companion DLL) so there's no apphost @@ -254,12 +260,12 @@ public async Task NativeBinary_HexSave_Succeeds() var tempDir = Path.Combine(Path.GetTempPath(), $"dotsider-test-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); var tempFile = Path.Combine(tempDir, "HelloWorld"); - File.Copy(samples.HelloWorldExe, tempFile); + File.Copy(Samples.HelloWorldExe, tempFile); try { var (terminal, app) = CreateDotsiderApp(tempFile); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -281,8 +287,8 @@ public async Task NativeBinary_HexSave_Succeeds() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Contains("written", _state!.HexNotification); - Assert.False(_state.HexIsDirty); + Assert.Contains("written", _state!.HexNotification!); + Assert.IsFalse(_state.HexIsDirty); cts.Cancel(); await runTask; @@ -296,57 +302,58 @@ public async Task NativeBinary_HexSave_Succeeds() /// /// Verifies in memory analyzer functions without disk. /// - [Fact] + [TestMethod] public void InMemoryAnalyzer_FunctionsWithoutDisk() { // Unit test for the in-memory fallback path that SaveHexChanges uses // when all disk candidates are exhausted - var originalBytes = File.ReadAllBytes(samples.HelloWorldDll); + var originalBytes = File.ReadAllBytes(Samples.HelloWorldDll); var fakePath = "/nonexistent/path/HelloWorld.dll"; using var analyzer = new AssemblyAnalyzer(originalBytes, fakePath); - Assert.Equal(fakePath, analyzer.FilePath); - Assert.Equal("HelloWorld.dll", analyzer.FileName); - Assert.Equal(originalBytes.Length, analyzer.FileSize); - Assert.True(analyzer.HasMetadata); - Assert.NotNull(analyzer.AssemblyName); - Assert.True(analyzer.RawBytes.Length > 0); + Assert.AreEqual(fakePath, analyzer.FilePath); + Assert.AreEqual("HelloWorld.dll", analyzer.FileName); + Assert.AreEqual(originalBytes.Length, analyzer.FileSize); + Assert.IsTrue(analyzer.HasMetadata); + Assert.IsNotNull(analyzer.AssemblyName); + Assert.IsGreaterThan(0, analyzer.RawBytes.Length); } /// /// Verifies in memory analyzer native binary save recovery path. /// - [Fact] + [TestMethod] public void InMemoryAnalyzer_NativeBinary_SaveRecoveryPath() { // Simulates the byte-array fallback in SaveHexChanges: after a hex // edit on a native binary (apphost/NativeAOT), all disk candidates // are exhausted and the analyzer is reconstructed from memory. - var originalBytes = File.ReadAllBytes(samples.HelloWorldExe); + var originalBytes = File.ReadAllBytes(Samples.HelloWorldExe); // Simulate a hex edit: modify a byte in the native binary var editedBytes = originalBytes.ToArray(); editedBytes[4] = 0xFF; - using var analyzer = new AssemblyAnalyzer(editedBytes, samples.HelloWorldExe); + using var analyzer = new AssemblyAnalyzer(editedBytes, Samples.HelloWorldExe); - Assert.Equal(samples.HelloWorldExe, analyzer.FilePath); - Assert.Equal(editedBytes.Length, analyzer.FileSize); - Assert.False(analyzer.HasMetadata); - Assert.False(analyzer.RawBytes.IsEmpty); - Assert.Equal(0xFF, analyzer.RawBytes.Span[4]); + Assert.AreEqual(Samples.HelloWorldExe, analyzer.FilePath); + Assert.AreEqual(editedBytes.Length, analyzer.FileSize); + Assert.IsFalse(analyzer.HasMetadata); + Assert.IsFalse(analyzer.RawBytes.IsEmpty); + Assert.AreEqual(0xFF, analyzer.RawBytes.Span[4]); } /// /// Verifies reopen or fallback all candidates fail returns in memory analyzer. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReopenOrFallback_AllCandidatesFail_ReturnsInMemoryAnalyzer() { // Exercises the candidate loop + byte-array fallback with all // non-existent paths so the in-memory branch triggers. - var originalBytes = File.ReadAllBytes(samples.HelloWorldExe); + var originalBytes = File.ReadAllBytes(Samples.HelloWorldExe); var editedBytes = originalBytes.ToArray(); editedBytes[4] = 0xFF; @@ -358,22 +365,23 @@ public void ReopenOrFallback_AllCandidatesFail_ReturnsInMemoryAnalyzer() ]; var (analyzer, resolvedPath) = DotsiderApp.ReopenOrFallback( - bogusPath, editedBytes, samples.HelloWorldExe); + bogusPath, editedBytes, Samples.HelloWorldExe); using (analyzer) { - Assert.Null(resolvedPath); - Assert.Equal(samples.HelloWorldExe, analyzer.FilePath); - Assert.False(analyzer.HasMetadata); - Assert.Equal(editedBytes.Length, analyzer.FileSize); - Assert.Equal(0xFF, analyzer.RawBytes.Span[4]); + Assert.IsNull(resolvedPath); + Assert.AreEqual(Samples.HelloWorldExe, analyzer.FilePath); + Assert.IsFalse(analyzer.HasMetadata); + Assert.AreEqual(editedBytes.Length, analyzer.FileSize); + Assert.AreEqual(0xFF, analyzer.RawBytes.Span[4]); } } /// /// Verifies save hex changes native binary memory fallback sets notification. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SaveHexChanges_NativeBinary_MemoryFallbackSetsNotification() { // Drives SaveHexChanges through the resolvedPath == null branch by @@ -382,7 +390,7 @@ public void SaveHexChanges_NativeBinary_MemoryFallbackSetsNotification() var tempDir = Path.Combine(Path.GetTempPath(), $"dotsider-test-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); var tempFile = Path.Combine(tempDir, "HelloWorld"); - File.Copy(samples.HelloWorldExe, tempFile); + File.Copy(Samples.HelloWorldExe, tempFile); try { @@ -402,16 +410,16 @@ public void SaveHexChanges_NativeBinary_MemoryFallbackSetsNotification() _state.HexEditorState.Document.ApplyBytes( new ByteReplaceOperation(4, 1, [0xFF])); _state.HexEditorState.IsReadOnly = true; - Assert.True(_state.HexIsDirty); + Assert.IsTrue(_state.HexIsDirty); // Inject a reopener that simulates all disk candidates failing DotsiderApp.SaveHexChanges(_state, reopener: (_, bytes, path) => (new AssemblyAnalyzer(bytes, path), null)); - Assert.Equal("Saved (working from memory — file may be locked)", _state.HexNotification); - Assert.False(_state.HexIsDirty); - Assert.False(_state.Analyzer.HasMetadata); - Assert.Equal(0xFF, _state.Analyzer.RawBytes.Span[4]); + Assert.AreEqual("Saved (working from memory — file may be locked)", _state.HexNotification); + Assert.IsFalse(_state.HexIsDirty); + Assert.IsFalse(_state.Analyzer.HasMetadata); + Assert.AreEqual(0xFF, _state.Analyzer.RawBytes.Span[4]); } finally { @@ -424,10 +432,10 @@ public void SaveHexChanges_NativeBinary_MemoryFallbackSetsNotification() /// public void Dispose() { + GC.SuppressFinalize(this); _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/HexSearchTests.cs b/tests/Dotsider.Tests/HexSearchTests.cs index 645e47e0..2e9cc2c7 100644 --- a/tests/Dotsider.Tests/HexSearchTests.cs +++ b/tests/Dotsider.Tests/HexSearchTests.cs @@ -6,156 +6,169 @@ namespace Dotsider.Tests; /// Tests for hex dump search: ASCII byte search, hex pattern parsing, /// multiple match offsets, and edge cases. /// +[TestClass] public class HexSearchTests { /// /// Verifies parse hex pattern valid bytes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ParseHexPattern_ValidBytes() { var (bytes, error) = HexDumpView.ParseHexPattern("FF D8 FF E0"); - Assert.Null(error); - Assert.NotNull(bytes); - Assert.Equal(4, bytes!.Length); - Assert.Equal(0xFF, bytes[0]); - Assert.Equal(0xD8, bytes[1]); - Assert.Equal(0xFF, bytes[2]); - Assert.Equal(0xE0, bytes[3]); + Assert.IsNull(error); + Assert.IsNotNull(bytes); + Assert.HasCount(4, bytes!); + Assert.AreEqual(0xFF, bytes[0]); + Assert.AreEqual(0xD8, bytes[1]); + Assert.AreEqual(0xFF, bytes[2]); + Assert.AreEqual(0xE0, bytes[3]); } /// /// Verifies parse hex pattern no spaces. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ParseHexPattern_NoSpaces() { var (bytes, error) = HexDumpView.ParseHexPattern("FFD8FFE0"); - Assert.Null(error); - Assert.NotNull(bytes); - Assert.Equal(4, bytes!.Length); + Assert.IsNull(error); + Assert.IsNotNull(bytes); + Assert.HasCount(4, bytes!); } /// /// Verifies parse hex pattern odd digits error. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ParseHexPattern_OddDigits_Error() { var (bytes, error) = HexDumpView.ParseHexPattern("FD8"); - Assert.Null(bytes); - Assert.Equal("Invalid hex: odd number of digits", error); + Assert.IsNull(bytes); + Assert.AreEqual("Invalid hex: odd number of digits", error); } /// /// Verifies parse hex pattern invalid chars error. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ParseHexPattern_InvalidChars_Error() { var (bytes, error) = HexDumpView.ParseHexPattern("GGZZ"); - Assert.Null(bytes); - Assert.Equal("Invalid hex pattern", error); + Assert.IsNull(bytes); + Assert.AreEqual("Invalid hex pattern", error); } /// /// Verifies parse hex pattern empty error. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ParseHexPattern_Empty_Error() { var (bytes, error) = HexDumpView.ParseHexPattern(""); - Assert.Null(bytes); - Assert.Equal("Invalid hex: empty pattern", error); + Assert.IsNull(bytes); + Assert.AreEqual("Invalid hex: empty pattern", error); } /// /// Verifies find byte pattern single match. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindBytePattern_SingleMatch() { byte[] data = [0x01, 0x02, 0x03, 0x04, 0x05]; byte[] pattern = [0x03, 0x04]; var offsets = HexDumpView.FindBytePattern(data, pattern); - Assert.Single(offsets); - Assert.Equal(2, offsets[0]); + Assert.ContainsSingle(offsets); + Assert.AreEqual(2, offsets[0]); } /// /// Verifies find byte pattern multiple matches. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindBytePattern_MultipleMatches() { byte[] data = [0xAA, 0xBB, 0xAA, 0xBB, 0xAA, 0xBB]; byte[] pattern = [0xAA, 0xBB]; var offsets = HexDumpView.FindBytePattern(data, pattern); - Assert.Equal(3, offsets.Count); - Assert.Equal(0, offsets[0]); - Assert.Equal(2, offsets[1]); - Assert.Equal(4, offsets[2]); + Assert.HasCount(3, offsets); + Assert.AreEqual(0, offsets[0]); + Assert.AreEqual(2, offsets[1]); + Assert.AreEqual(4, offsets[2]); } /// /// Verifies find byte pattern no match. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindBytePattern_NoMatch() { byte[] data = [0x01, 0x02, 0x03]; byte[] pattern = [0xFF, 0xFE]; var offsets = HexDumpView.FindBytePattern(data, pattern); - Assert.Empty(offsets); + Assert.IsEmpty(offsets); } /// /// Verifies find byte pattern empty data. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindBytePattern_EmptyData() { byte[] data = []; byte[] pattern = [0xFF]; var offsets = HexDumpView.FindBytePattern(data, pattern); - Assert.Empty(offsets); + Assert.IsEmpty(offsets); } /// /// Verifies find byte pattern empty pattern. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindBytePattern_EmptyPattern() { byte[] data = [0x01, 0x02]; byte[] pattern = []; var offsets = HexDumpView.FindBytePattern(data, pattern); - Assert.Empty(offsets); + Assert.IsEmpty(offsets); } /// /// Verifies find byte pattern pattern longer than data. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindBytePattern_PatternLongerThanData() { byte[] data = [0x01]; byte[] pattern = [0x01, 0x02, 0x03]; var offsets = HexDumpView.FindBytePattern(data, pattern); - Assert.Empty(offsets); + Assert.IsEmpty(offsets); } /// /// Verifies find byte pattern ascii text search. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindBytePattern_AsciiTextSearch() { byte[] data = System.Text.Encoding.ASCII.GetBytes("Hello World Hello"); byte[] pattern = System.Text.Encoding.ASCII.GetBytes("Hello"); var offsets = HexDumpView.FindBytePattern(data, pattern); - Assert.Equal(2, offsets.Count); - Assert.Equal(0, offsets[0]); - Assert.Equal(12, offsets[1]); + Assert.HasCount(2, offsets); + Assert.AreEqual(0, offsets[0]); + Assert.AreEqual(12, offsets[1]); } } diff --git a/tests/Dotsider.Tests/IlColorizerTests.cs b/tests/Dotsider.Tests/IlColorizerTests.cs index aad18824..cbfb2aea 100644 --- a/tests/Dotsider.Tests/IlColorizerTests.cs +++ b/tests/Dotsider.Tests/IlColorizerTests.cs @@ -6,6 +6,7 @@ namespace Dotsider.Tests; /// /// Tests for IL disassembly syntax coloring. /// +[TestClass] public class IlColorizerTests { private const string Reset = "\x1b[0m"; @@ -22,30 +23,33 @@ public class IlColorizerTests /// /// Verifies empty line returns unchanged. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void EmptyLine_ReturnsUnchanged() { - Assert.Equal("", IlColorizer.ColorizeLine("")); + Assert.AreEqual("", IlColorizer.ColorizeLine("")); } /// /// Verifies whitespace line returns unchanged. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void WhitespaceLine_ReturnsUnchanged() { var line = " "; - Assert.Equal(line, IlColorizer.ColorizeLine(line)); + Assert.AreEqual(line, IlColorizer.ColorizeLine(line)); } /// /// Verifies unrecognized line passes through. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void UnrecognizedLine_PassesThrough() { var line = "some other text"; - Assert.Equal(line, IlColorizer.ColorizeLine(line)); + Assert.AreEqual(line, IlColorizer.ColorizeLine(line)); } // --- Comment coloring --- @@ -53,33 +57,36 @@ public void UnrecognizedLine_PassesThrough() /// /// Verifies comment wrapped in comment color. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Comment_WrappedInCommentColor() { var line = "// Method: Foo::Bar"; var result = IlColorizer.ColorizeLine(line); - Assert.Equal($"{CommentFg}{line}{Reset}", result); + Assert.AreEqual($"{CommentFg}{line}{Reset}", result); } /// /// Verifies indented comment wrapped in comment color. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void IndentedComment_WrappedInCommentColor() { var line = " // RVA: 0x00002050"; var result = IlColorizer.ColorizeLine(line); - Assert.Equal($"{CommentFg}{line}{Reset}", result); + Assert.AreEqual($"{CommentFg}{line}{Reset}", result); } /// /// Verifies locals init directive is colored without coloring the rest of the line. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void LocalsInit_ColorsDirectiveOnly() { var result = IlColorizer.ColorizeLine(" .locals init ("); - Assert.Equal($" {DirectiveFg}.locals init{Reset} (", result); + Assert.AreEqual($" {DirectiveFg}.locals init{Reset} (", result); } // --- Instruction coloring --- @@ -87,17 +94,19 @@ public void LocalsInit_ColorsDirectiveOnly() /// /// Verifies opcode only colors address and opcode. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void OpcodeOnly_ColorsAddressAndOpcode() { var result = IlColorizer.ColorizeLine("IL_0005: ret"); - Assert.Equal($"{AddressFg}IL_0005:{Reset} {OpcodeFg}ret{Reset}", result); + Assert.AreEqual($"{AddressFg}IL_0005:{Reset} {OpcodeFg}ret{Reset}", result); } /// /// Verifies opcode with non string operand preserves operand uncolored. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void OpcodeWithNonStringOperand_PreservesOperandUncolored() { var result = IlColorizer.ColorizeLine("IL_000B: call System.Console::WriteLine(string)"); @@ -110,7 +119,8 @@ public void OpcodeWithNonStringOperand_PreservesOperandUncolored() /// /// Verifies branch target preserves target label. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void BranchTarget_PreservesTargetLabel() { var result = IlColorizer.ColorizeLine("IL_000A: br.s IL_0010"); @@ -121,7 +131,8 @@ public void BranchTarget_PreservesTargetLabel() /// /// Verifies numeric operand preserves uncolored. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NumericOperand_PreservesUncolored() { var result = IlColorizer.ColorizeLine("IL_0002: ldc.i4.s 42"); @@ -135,7 +146,8 @@ public void NumericOperand_PreservesUncolored() /// /// Verifies string operand colored green. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void StringOperand_ColoredGreen() { var result = IlColorizer.ColorizeLine("IL_0006: ldstr \"hello world\""); @@ -147,19 +159,21 @@ public void StringOperand_ColoredGreen() /// /// Verifies escaped quotes stay single segment. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void EscapedQuotes_StaySingleSegment() { var result = IlColorizer.ColorizeLine("IL_0006: ldstr \"say \\\"hi\\\"\""); Assert.Contains(StringFg, result); // Should open StringFg exactly once for the whole quoted segment - Assert.Equal(1, CountOccurrences(result, StringFg)); + Assert.AreEqual(1, CountOccurrences(result, StringFg)); } /// /// Verifies unmatched quote ends with reset. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void UnmatchedQuote_EndsWithReset() { var result = IlColorizer.ColorizeLine("IL_0006: ldstr \"unclosed"); @@ -172,49 +186,53 @@ public void UnmatchedQuote_EndsWithReset() /// /// Verifies malformed il line no separator passes through. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void MalformedIlLine_NoSeparator_PassesThrough() { var line = "IL_NOSEPARATOR"; var result = IlColorizer.ColorizeLine(line); - Assert.Equal(line, result); + Assert.AreEqual(line, result); } /// /// Verifies empty body after separator colors address only. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void EmptyBodyAfterSeparator_ColorsAddressOnly() { var result = IlColorizer.ColorizeLine("IL_0000:"); // No ": " separator (needs colon+space), so treated as malformed - Assert.Equal("IL_0000:", result); + Assert.AreEqual("IL_0000:", result); } /// /// Verifies all resets paired instruction line. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AllResetsPaired_InstructionLine() { var result = IlColorizer.ColorizeLine("IL_0001: call System.Object::.ctor()"); // Each colored span (address, opcode) should have a matching reset var ansiOpens = CountOccurrences(result, "\x1b[38;"); var resets = CountOccurrences(result, Reset); - Assert.Equal(ansiOpens, resets); + Assert.AreEqual(ansiOpens, resets); } /// /// Verifies all resets paired string operand. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AllResetsPaired_StringOperand() { var result = IlColorizer.ColorizeLine("IL_0006: ldstr \"test\""); // address + opcode + string = 3 colored spans, 3 resets var ansiOpens = CountOccurrences(result, "\x1b[38;"); var resets = CountOccurrences(result, Reset); - Assert.Equal(ansiOpens, resets); + Assert.AreEqual(ansiOpens, resets); } private static int CountOccurrences(string text, string pattern) diff --git a/tests/Dotsider.Tests/IlDisassemblerTests.cs b/tests/Dotsider.Tests/IlDisassemblerTests.cs index 99dcdc2d..aa484a68 100644 --- a/tests/Dotsider.Tests/IlDisassemblerTests.cs +++ b/tests/Dotsider.Tests/IlDisassemblerTests.cs @@ -6,46 +6,51 @@ namespace Dotsider.Tests; /// /// Tests for Il Disassembler. /// -[Collection("SampleAssemblies")] -public class IlDisassemblerTests(SampleAssemblyFixture samples) +[TestClass] +public class IlDisassemblerTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// Verifies hello world main method contains call and ret. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HelloWorld_MainMethod_ContainsCallAndRet() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); var disasm = new IlDisassembler(a); var mainMethod = a.MethodDefs.FirstOrDefault(m => m.Name == "
$" || m.Name == "Main"); - Assert.NotNull(mainMethod); + Assert.IsNotNull(mainMethod); var instructions = disasm.Disassemble(mainMethod); - Assert.NotEmpty(instructions); - Assert.Contains(instructions, i => i.OpCode.Contains("ret")); + Assert.IsNotEmpty(instructions); + Assert.Contains(i => i.OpCode.Contains("ret"), instructions); } /// /// Verifies rich library user service add disassembles successfully. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_UserServiceAdd_DisassemblesSuccessfully() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var disasm = new IlDisassembler(a); var method = a.MethodDefs.FirstOrDefault(m => m.DeclaringType == "RichLibrary.Services.UserService" && m.Name == "Add"); - Assert.NotNull(method); + Assert.IsNotNull(method); var instructions = disasm.Disassemble(method); - Assert.NotEmpty(instructions); + Assert.IsNotEmpty(instructions); } /// /// Verifies formatted IL includes portable PDB annotations. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_UserServiceAdd_FormatIncludesPortablePdbAnnotations() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var disasm = new IlDisassembler(a); var method = FindMethod(a, "RichLibrary.Services.UserService", "Add"); @@ -64,39 +69,40 @@ public void RichLibrary_UserServiceAdd_FormatIncludesPortablePdbAnnotations() /// /// Verifies decoded IL instructions carry sequence point and local variable metadata. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_UserServiceAdd_InstructionsIncludeDebugMetadata() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var disasm = new IlDisassembler(a); var method = FindMethod(a, "RichLibrary.Services.UserService", "Add"); var result = disasm.DisassembleWithText(method); - Assert.NotNull(result); - Assert.Contains(result.Value.Instructions, - instruction => instruction.SequenceDocument?.EndsWith("UserService.cs", + Assert.IsNotNull(result); + Assert.Contains(instruction => instruction.SequenceDocument?.EndsWith("UserService.cs", StringComparison.OrdinalIgnoreCase) == true - && instruction.SourceLinkUrl is not null); - Assert.Contains(result.Value.Instructions, instruction => instruction.LocalName == "id"); - Assert.Contains(result.Value.Instructions, instruction => instruction.LocalName == "user"); - Assert.All(result.Value.Instructions, - instruction => Assert.True(instruction.DisplayLine is null or > 0)); + && instruction.SourceLinkUrl is not null, result.Value.Instructions); + Assert.Contains(instruction => instruction.LocalName == "id", result.Value.Instructions); + Assert.Contains(instruction => instruction.LocalName == "user", result.Value.Instructions); + TestAssert.All(result.Value.Instructions, + instruction => Assert.IsTrue(instruction.DisplayLine is null or > 0)); } /// /// Verifies formatted IL prints one Source Link marker per distinct URL. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_UserServiceAdd_FormatDeduplicatesSourceLinkMarkers() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var disasm = new IlDisassembler(a); var method = FindMethod(a, "RichLibrary.Services.UserService", "Add"); var result = disasm.DisassembleWithText(method); - Assert.NotNull(result); + Assert.IsNotNull(result); var markerCount = result.Value.Text .Split('\n') .Count(line => line.Contains("[source link]", StringComparison.Ordinal)); @@ -107,14 +113,15 @@ public void RichLibrary_UserServiceAdd_FormatDeduplicatesSourceLinkMarkers() .Distinct(StringComparer.OrdinalIgnoreCase) .Count(); - Assert.True(distinctUrlCount > 0); - Assert.Equal(distinctUrlCount, markerCount); + Assert.IsGreaterThan(0, distinctUrlCount); + Assert.AreEqual(distinctUrlCount, markerCount); } /// /// Verifies hidden sequence points do not consume the first visible Source Link marker. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Dotsider_IlInspectorViewMethod_HiddenPointDoesNotConsumeSourceLinkMarker() { using var a = new AssemblyAnalyzer(typeof(DotsiderApp).Assembly.Location); @@ -123,175 +130,186 @@ public void Dotsider_IlInspectorViewMethod_HiddenPointDoesNotConsumeSourceLinkMa var result = disasm.DisassembleWithText(method); - Assert.NotNull(result); + Assert.IsNotNull(result); var lines = result.Value.Text.Split('\n'); - Assert.Contains(lines, line => line == "// (hidden)"); + Assert.Contains(line => line == "// (hidden)", lines); var firstVisibleSourceLine = lines.First(line => line.Contains("IlInspectorView.cs(", StringComparison.Ordinal)); Assert.Contains("[source link]", firstVisibleSourceLine); - Assert.DoesNotContain("[source link]", lines.First(line => line == "// (hidden)")); + Assert.DoesNotContain(lines.First(line => line == "// (hidden)"), "[source link]"); } /// /// Verifies native lib unsafe method has distinct opcodes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeLib_UnsafeMethod_HasDistinctOpcodes() { - using var a = new AssemblyAnalyzer(samples.NativeLibDll); + using var a = new AssemblyAnalyzer(Samples.NativeLibDll); var disasm = new IlDisassembler(a); var method = a.MethodDefs.FirstOrDefault(m => m.Name == "SumWithPointers"); - Assert.NotNull(method); + Assert.IsNotNull(method); var instructions = disasm.Disassemble(method); - Assert.NotEmpty(instructions); + Assert.IsNotEmpty(instructions); } /// /// Verifies format disassembly returns readable text. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FormatDisassembly_ReturnsReadableText() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); var disasm = new IlDisassembler(a); var method = a.MethodDefs.First(m => m.Rva != 0); var text = disasm.FormatDisassembly(method); - Assert.NotNull(text); - Assert.NotEmpty(text); + Assert.IsNotNull(text); + Assert.IsNotEmpty(text); Assert.Contains("IL_", text); } /// /// Verifies all rich library methods disassemble without throwing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AllRichLibraryMethods_DisassembleWithoutThrowing() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var disasm = new IlDisassembler(a); foreach (var method in a.MethodDefs.Where(m => m.Rva != 0)) { var instructions = disasm.Disassemble(method); - Assert.NotNull(instructions); + Assert.IsNotNull(instructions); } } /// /// Verifies method with no body returns empty list. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void MethodWithNoBody_ReturnsEmptyList() { - using var a = new AssemblyAnalyzer(samples.NativeLibDll); + using var a = new AssemblyAnalyzer(Samples.NativeLibDll); var disasm = new IlDisassembler(a); // P/Invoke methods have Rva == 0, no IL body var externMethod = a.MethodDefs.FirstOrDefault(m => m.Rva == 0); if (externMethod is not null) { var instructions = disasm.Disassemble(externMethod); - Assert.Empty(instructions); + Assert.IsEmpty(instructions); } } /// /// Verifies empty lib can construct no methods to disassemble. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void EmptyLib_CanConstruct_NoMethodsToDisassemble() { - using var a = new AssemblyAnalyzer(samples.EmptyLibDll); + using var a = new AssemblyAnalyzer(Samples.EmptyLibDll); var disasm = new IlDisassembler(a); var methodsWithIl = a.MethodDefs.Where(m => m.Rva != 0).ToList(); // Either no methods or only compiler-generated - Assert.True(methodsWithIl.Count <= 2); + Assert.IsLessThanOrEqualTo(2, methodsWithIl.Count); } /// /// Verifies complex app async method disassembles successfully. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ComplexApp_AsyncMethod_DisassemblesSuccessfully() { - using var a = new AssemblyAnalyzer(samples.ComplexAppDll); + using var a = new AssemblyAnalyzer(Samples.ComplexAppDll); var disasm = new IlDisassembler(a); var asyncMethods = a.MethodDefs.Where(m => m.Name.Contains("MoveNext")).ToList(); if (asyncMethods.Count > 0) { var instructions = disasm.Disassemble(asyncMethods[0]); - Assert.NotEmpty(instructions); + Assert.IsNotEmpty(instructions); } } /// /// Verifies minimal api methods disassemble without throwing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void MinimalApi_Methods_DisassembleWithoutThrowing() { - using var a = new AssemblyAnalyzer(samples.MinimalApiDll); + using var a = new AssemblyAnalyzer(Samples.MinimalApiDll); var disasm = new IlDisassembler(a); foreach (var method in a.MethodDefs.Where(m => m.Rva != 0).Take(20)) { var instructions = disasm.Disassemble(method); - Assert.NotNull(instructions); + Assert.IsNotNull(instructions); } } /// /// Verifies disassemble instruction has offset. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Disassemble_InstructionHasOffset() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); var disasm = new IlDisassembler(a); var method = a.MethodDefs.First(m => m.Rva != 0); var instructions = disasm.Disassemble(method); - Assert.NotEmpty(instructions); + Assert.IsNotEmpty(instructions); // First instruction should be at offset 0 - Assert.Equal(0, instructions[0].Offset); + Assert.AreEqual(0, instructions[0].Offset); } /// /// Verifies native lib stack alloc sum has instructions. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeLib_StackAllocSum_HasInstructions() { - using var a = new AssemblyAnalyzer(samples.NativeLibDll); + using var a = new AssemblyAnalyzer(Samples.NativeLibDll); var disasm = new IlDisassembler(a); var method = a.MethodDefs.FirstOrDefault(m => m.Name == "StackAllocSum"); - Assert.NotNull(method); + Assert.IsNotNull(method); var instructions = disasm.Disassemble(method); - Assert.NotEmpty(instructions); + Assert.IsNotEmpty(instructions); } /// /// Verifies instruction has op code and operand. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Instruction_HasOpCodeAndOperand() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var disasm = new IlDisassembler(a); var method = a.MethodDefs.First(m => m.Rva != 0 && m.Name != ".ctor"); var instructions = disasm.Disassemble(method); - Assert.NotEmpty(instructions); + Assert.IsNotEmpty(instructions); foreach (var inst in instructions) { - Assert.NotNull(inst.OpCode); - Assert.NotNull(inst.Operand); // can be empty string, not null + Assert.IsNotNull(inst.OpCode); + Assert.IsNotNull(inst.Operand); // can be empty string, not null } } /// /// Verifies format disassembly contains hex offsets. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FormatDisassembly_ContainsHexOffsets() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var disasm = new IlDisassembler(a); var method = a.MethodDefs.First(m => m.Rva != 0); var text = disasm.FormatDisassembly(method); @@ -301,25 +319,27 @@ public void FormatDisassembly_ContainsHexOffsets() /// /// Verifies complex app all methods disassemble without throwing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ComplexApp_AllMethods_DisassembleWithoutThrowing() { - using var a = new AssemblyAnalyzer(samples.ComplexAppDll); + using var a = new AssemblyAnalyzer(Samples.ComplexAppDll); var disasm = new IlDisassembler(a); foreach (var method in a.MethodDefs.Where(m => m.Rva != 0)) { var instructions = disasm.Disassemble(method); - Assert.NotNull(instructions); + Assert.IsNotNull(instructions); } } /// /// Verifies rich library method with string operand contains ldstr. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_MethodWithStringOperand_ContainsLdstr() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var disasm = new IlDisassembler(a); // Find any method that loads a string literal foreach (var method in a.MethodDefs.Where(m => m.Rva != 0)) @@ -328,7 +348,7 @@ public void RichLibrary_MethodWithStringOperand_ContainsLdstr() var ldstr = instructions.FirstOrDefault(i => i.OpCode.Contains("ldstr")); if (ldstr is not null) { - Assert.NotEmpty(ldstr.Operand); + Assert.IsNotEmpty(ldstr.Operand); return; } } @@ -337,10 +357,11 @@ public void RichLibrary_MethodWithStringOperand_ContainsLdstr() /// /// Verifies rich library method with branches contains br. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_MethodWithBranches_ContainsBr() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var disasm = new IlDisassembler(a); foreach (var method in a.MethodDefs.Where(m => m.Rva != 0)) { @@ -358,10 +379,11 @@ public void RichLibrary_MethodWithBranches_ContainsBr() /// /// Verifies rich library method call instructions resolve tokens. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_MethodCallInstructions_ResolveTokens() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var disasm = new IlDisassembler(a); foreach (var method in a.MethodDefs.Where(m => m.Rva != 0)) { @@ -370,7 +392,7 @@ public void RichLibrary_MethodCallInstructions_ResolveTokens() i.OpCode == "call" || i.OpCode == "callvirt" || i.OpCode == "newobj"); if (call is not null) { - Assert.NotEmpty(call.Operand); + Assert.IsNotEmpty(call.Operand); // Resolved token should not be just hex Assert.DoesNotContain("0x", call.Operand); return; @@ -384,7 +406,7 @@ private static MethodDefInfo FindMethod(AssemblyAnalyzer analyzer, string typeNa m.DeclaringType == typeName && m.Name == methodName); - Assert.NotNull(method); + Assert.IsNotNull(method); return method; } } diff --git a/tests/Dotsider.Tests/IlEditorDoubleClickIntegrationTests.cs b/tests/Dotsider.Tests/IlEditorDoubleClickIntegrationTests.cs index bf32e94b..7309c144 100644 --- a/tests/Dotsider.Tests/IlEditorDoubleClickIntegrationTests.cs +++ b/tests/Dotsider.Tests/IlEditorDoubleClickIntegrationTests.cs @@ -11,18 +11,21 @@ namespace Dotsider.Tests; /// in the IL Inspector, exercising the full mouse → EditorNode → one-shot /// cursor adjustment → yank pipeline. /// -[Collection("SampleAssemblies")] -public class IlEditorDoubleClickIntegrationTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class IlEditorDoubleClickIntegrationTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _hex1bApp; private DotsiderState? _state; private CancellationTokenSource? _cts; + private Task? _runTask; private (Hex1bTerminal terminal, Hex1bApp app, CancellationToken ct) CreateDotsiderApp(string dllPath) { - _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + _cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); _workload = new Hex1bAppWorkloadAdapter(); _terminal = Hex1bTerminal.CreateBuilder() .WithWorkload(_workload) @@ -48,6 +51,20 @@ public class IlEditorDoubleClickIntegrationTests(SampleAssemblyFixture samples) return (_terminal, _hex1bApp, _cts.Token); } + private Task RunAppAsync(Hex1bApp app, CancellationToken ct) + { + _runTask = app.RunAsync(ct); + return _runTask; + } + + private bool TryWaitForAppExit() + { + if (_runTask is null) return true; + try { return _runTask.Wait(TimeSpan.FromSeconds(5)); } + catch (AggregateException ex) when (ex.InnerExceptions.All(static e => e is OperationCanceledException)) { return true; } + catch (OperationCanceledException) { return true; } + } + /// /// Expands the tree path for a method and selects it in the IL Inspector. /// @@ -61,6 +78,7 @@ private void SelectMethodInTree( _state.IlSelectedMethod = method; _state.IlFocusedTreeKey = $"method:{method.Token}"; _state.App.Invalidate(); + _state.RequestExtraFrame(); } /// @@ -68,11 +86,12 @@ private void SelectMethodInTree( /// through the full render pipeline. Verifies the one-shot cursor adjustment fires /// correctly and that yank produces the right text and cursor position. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SelectWordAt_ThroughRenderPipeline_AdjustsCursorAndYankWorks() { - var (terminal, app, ct) = CreateDotsiderApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateDotsiderApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await Task.Delay(50, ct); // Navigate to IL Inspector tab @@ -92,22 +111,22 @@ public async Task SelectWordAt_ThroughRenderPipeline_AdjustsCursorAndYankWorks() await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.ContainsText("IL_0000"), TimeSpan.FromSeconds(10)) + .WaitUntil(_ => _state!.IlEditorMethod?.Token == method.Token + && _state.IlEditorState?.Document.GetText().Contains("System.", StringComparison.Ordinal) == true, + TimeSpan.FromSeconds(10)) .Build() .ApplyAsync(terminal, ct); - // Find first "System." in the disassembly - var disassembly = _state.IlDisassembler!.FormatDisassembly(method); - var systemIdx = disassembly.IndexOf("System.", StringComparison.Ordinal); - Assert.True(systemIdx >= 0, "Expected 'System.' in disassembly"); - - var doc = _state.IlEditorState!.Document; - - // Let a render cycle seed the one-shot tracking state - await Task.Delay(50, ct); + var editorState = _state.IlEditorState!; + var doc = editorState.Document; + var fullText = doc.GetText(); + var systemIdx = fullText.IndexOf("System.", StringComparison.Ordinal); + Assert.IsGreaterThanOrEqualTo(0, systemIdx, "Expected 'System.' in rendered IL editor document"); // Simulate double-click: SelectWordAt is what EditorNode.cs:281 calls - _state.IlEditorState!.SelectWordAt(new DocumentOffset(systemIdx)); + editorState.SelectWordAt(new DocumentOffset(systemIdx)); _state.App.Invalidate(); + _state.RequestExtraFrame(); // Wait for the render cycle to process AdjustWordSelectionCursorOneShot — // the one-shot pulls the cursor from the trailing '.' back onto the last @@ -115,7 +134,8 @@ public async Task SelectWordAt_ThroughRenderPipeline_AdjustsCursorAndYankWorks() await TestHelpers.WaitUntilAsync( () => { - var es = _state.IlEditorState!; + if (!ReferenceEquals(_state.IlEditorState, editorState)) return false; + var es = editorState; if (!es.Cursor.HasSelection) return false; var pos = es.Cursor.Position.Value; var text = es.Document.GetText(); @@ -123,26 +143,25 @@ await TestHelpers.WaitUntilAsync( }, TimeSpan.FromSeconds(5)); - var fullText = doc.GetText(); - var cursorOffset = _state.IlEditorState!.Cursor.Position.Value; + fullText = doc.GetText(); + var cursorOffset = editorState.Cursor.Position.Value; // Cursor must be on last word char ('m' of "System"), not on '.' - Assert.True(cursorOffset < fullText.Length, - "Cursor should be within document bounds"); - Assert.Equal('m', fullText[cursorOffset]); + Assert.IsLessThan(fullText.Length, cursorOffset, "Cursor should be within document bounds"); + Assert.AreEqual('m', fullText[cursorOffset]); // Yank must copy the full word "System" - var range = _state.IlEditorState!.Cursor.SelectionRange; + var range = editorState.Cursor.SelectionRange; var yankEnd = new DocumentOffset(Math.Min( - Math.Max(range.End.Value, _state.IlEditorState!.Cursor.Position.Value + 1), + Math.Max(range.End.Value, editorState.Cursor.Position.Value + 1), doc.Length)); var yankRange = new DocumentRange(range.Start, yankEnd); var yankText = doc.GetText(yankRange); - Assert.Equal("System", yankText); + Assert.AreEqual("System", yankText); // Post-yank cursor must land on 'm' var postYankCursor = new DocumentOffset(Math.Max(0, yankEnd.Value - 1)); - Assert.Equal('m', fullText[postYankCursor.Value]); + Assert.AreEqual('m', fullText[postYankCursor.Value]); _cts!.Cancel(); await runTask; @@ -153,11 +172,12 @@ await TestHelpers.WaitUntilAsync( /// SGR mouse events through the terminal. Verifies EditorNode processes the /// double-click, calls SelectWordAt, and the one-shot cursor adjustment fires. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task DoubleClickAt_SgrMouse_SelectsWordAndAdjustsCursor() { - var (terminal, app, ct) = CreateDotsiderApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateDotsiderApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await Task.Delay(50, ct); // Navigate to IL Inspector tab @@ -193,25 +213,29 @@ public async Task DoubleClickAt_SgrMouse_SelectsWordAndAdjustsCursor() }, TimeSpan.FromSeconds(10)) .Build() .ApplyAsync(terminal, ct); - Assert.True(allMatches.Count > 0, "Expected 'System.' visible on screen"); + Assert.IsGreaterThan(0, allMatches.Count, "Expected 'System.' visible on screen"); // Use the first match — coordinates are 0-based var (targetRow, targetCol) = allMatches[0]; - // Single click first to give the editor focus (tree panel has focus by default). - // Wait for the click to be processed — the editor cursor position changes - // when the editor receives focus and handles the mouse event. + // Focus the editor without consuming a mouse click, then queue two real + // clicks so Hex1bApp's click-count state machine observes a double-click. var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(5)); - await auto.ClickAtAsync(targetCol, targetRow, ct: ct); - await new Hex1bTerminalInputSequenceBuilder() - .WaitUntil(_ => _state.IlEditorState?.Cursor.Position.Value > 0, TimeSpan.FromSeconds(5)) - .Build() - .ApplyAsync(terminal, ct); + _state!.App.RequestFocus(node => + node is EditorNode { State: var es } && es == _state.IlEditorState); + _state.App.Invalidate(); + await auto.WaitUntilAsync(_ => + _state.App.FocusedNode is EditorNode { State: var es } && es == _state.IlEditorState, + description: "IL editor focused"); // Double-click to select the word. Wait for HasSelection AND for the // AdjustWordSelectionCursorOneShot to fire (runs on the next Build after // selection, pulling the cursor back from punctuation to a word character). - await auto.DoubleClickAtAsync(targetCol, targetRow, ct: ct); + await new Hex1bTerminalInputSequenceBuilder() + .ClickAt(targetCol, targetRow) + .ClickAt(targetCol, targetRow) + .Build() + .ApplyAsync(terminal, ct); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(_ => { @@ -225,24 +249,23 @@ public async Task DoubleClickAt_SgrMouse_SelectsWordAndAdjustsCursor() .ApplyAsync(terminal, ct); // Verify selection happened - Assert.True(_state.IlEditorState!.Cursor.HasSelection, + Assert.IsTrue(_state.IlEditorState!.Cursor.HasSelection, "Double-click should create a selection"); // Cursor must be on a word character, not punctuation var doc = _state.IlEditorState!.Document; var fullText = doc.GetText(); var cursorVal = _state.IlEditorState!.Cursor.Position.Value; - Assert.True(cursorVal < fullText.Length, - "Cursor should be within document bounds"); - Assert.True(char.IsLetterOrDigit(fullText[cursorVal]), + Assert.IsLessThan(fullText.Length, cursorVal, "Cursor should be within document bounds"); + Assert.IsTrue(char.IsLetterOrDigit(fullText[cursorVal]), $"Cursor should be on a word character after double-click, not '{fullText[cursorVal]}' at offset {cursorVal}"); // The selected text (via SelectionRange) should be pure word chars. // After one-shot adjustment, SelectionRange is one char short (cursor on last char), // which is correct — the yank logic adds +1 to compensate. var selected = doc.GetText(_state.IlEditorState!.Cursor.SelectionRange); - Assert.True(selected.Length > 0, "Selection must not be empty"); - Assert.True(selected.All(char.IsLetterOrDigit), + Assert.IsGreaterThan(0, selected.Length, "Selection must not be empty"); + Assert.IsTrue(selected.All(char.IsLetterOrDigit), $"Selected text should be a pure word, got '{selected}'"); // Verify the yank range (cursor.Position + 1) recovers the full word @@ -263,12 +286,18 @@ public async Task DoubleClickAt_SgrMouse_SelectsWordAndAdjustsCursor() /// public void Dispose() { + GC.SuppressFinalize(this); _cts?.Cancel(); + if (!TryWaitForAppExit()) + { + _hex1bApp?.Dispose(); + _terminal?.Dispose(); + _ = TryWaitForAppExit(); + } _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); _cts?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/IlEditorLifecycleTests.cs b/tests/Dotsider.Tests/IlEditorLifecycleTests.cs index 67eb6779..240528bf 100644 --- a/tests/Dotsider.Tests/IlEditorLifecycleTests.cs +++ b/tests/Dotsider.Tests/IlEditorLifecycleTests.cs @@ -13,9 +13,11 @@ namespace Dotsider.Tests; /// Tests for IL editor lifecycle: StatePanelWidget-based editor caching, /// tree list per-render sync, and field pane staleness. /// -[Collection("SampleAssemblies")] -public class IlEditorLifecycleTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class IlEditorLifecycleTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _hex1bApp; @@ -40,7 +42,7 @@ private Hex1bApp CreateMinimalApp() private (Hex1bTerminal terminal, Hex1bApp app, CancellationToken ct) CreateDotsiderApp(string dllPath) { - _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + _cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); _workload = new Hex1bAppWorkloadAdapter(); _terminal = Hex1bTerminal.CreateBuilder() .WithWorkload(_workload) @@ -124,55 +126,59 @@ private void RequestEditorFocusForRender() /// /// Verifies get or create editor key same analyzer and token returns same reference. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetOrCreateEditorKey_SameAnalyzerAndToken_ReturnsSameReference() { var app = CreateMinimalApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); var key1 = state.GetOrCreateEditorKey(state.Analyzer, 0x06000001); var key2 = state.GetOrCreateEditorKey(state.Analyzer, 0x06000001); - Assert.Same(key1, key2); + Assert.AreSame(key1, key2); } /// /// Verifies get or create editor key different tokens returns different references. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetOrCreateEditorKey_DifferentTokens_ReturnsDifferentReferences() { var app = CreateMinimalApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); var key1 = state.GetOrCreateEditorKey(state.Analyzer, 0x06000001); var key2 = state.GetOrCreateEditorKey(state.Analyzer, 0x06000002); - Assert.NotSame(key1, key2); + Assert.AreNotSame(key1, key2); } /// /// Verifies set il focused tree key updates focused tree key. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SetIlFocusedTreeKey_UpdatesFocusedTreeKey() { var app = CreateMinimalApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.SetIlFocusedTreeKey("method:0x06000001"); - Assert.Equal("method:0x06000001", state.IlFocusedTreeKey); + Assert.AreEqual("method:0x06000001", state.IlFocusedTreeKey); } /// /// Verifies restore from il back entry reseeds editor key cache. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RestoreFromIlBackEntry_ReseedsEditorKeyCache() { var app = CreateMinimalApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); var method = state.Analyzer.MethodDefs[0]; var editorKey = state.GetOrCreateEditorKey(state.Analyzer, method.Token); @@ -194,17 +200,18 @@ public void RestoreFromIlBackEntry_ReseedsEditorKeyCache() // GetOrCreateEditorKey must return the reseeded key var restoredKey = state.GetOrCreateEditorKey(state.Analyzer, method.Token); - Assert.Same(editorKey, restoredKey); + Assert.AreSame(editorKey, restoredKey); } /// /// Verifies restore from il back entry saves outgoing editor. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RestoreFromIlBackEntry_SavesOutgoingEditor() { var app = CreateMinimalApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); if (state.Analyzer.MethodDefs.Count < 2) return; var methodB = state.Analyzer.MethodDefs[1]; @@ -226,18 +233,19 @@ public void RestoreFromIlBackEntry_SavesOutgoingEditor() state.RestoreFromIlBackEntry(entry); - Assert.True(state.IlCachedEditors.ContainsKey(keyB)); - Assert.Same(editorStateB, state.IlCachedEditors[keyB]); + Assert.IsTrue(state.IlCachedEditors.ContainsKey(keyB)); + Assert.AreSame(editorStateB, state.IlCachedEditors[keyB]); } /// /// Verifies back entry preserves editor key. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void BackEntry_PreservesEditorKey() { var app = CreateMinimalApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); if (state.Analyzer.MethodDefs.Count < 2) return; var methodA = state.Analyzer.MethodDefs[0]; @@ -251,9 +259,9 @@ public void BackEntry_PreservesEditorKey() var methodB = state.Analyzer.MethodDefs[1]; state.NavigateToIlDefinition(methodB.Token); - Assert.True(state.IlBackStack.Count > 0); + Assert.IsGreaterThan(0, state.IlBackStack.Count); var entry = state.IlBackStack.Peek(); - Assert.Same(keyA, entry.EditorKey); + Assert.AreSame(keyA, entry.EditorKey); } // ── Integration tests (real IL Inspector) ───────────────── @@ -262,10 +270,11 @@ public void BackEntry_PreservesEditorKey() /// After switching methods via the tree and switching back, the EditorNode's scroll /// offset should be preserved (node kept alive by StatePanelWidget cache). /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task EditorScroll_PreservedOnTreeRevisit() { - var (terminal, app, ct) = CreateDotsiderApp(samples.RichLibraryDll); + var (terminal, app, ct) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -283,10 +292,10 @@ public async Task EditorScroll_PreservedOnTreeRevisit() // Capture the EditorNode's scroll offset after moving down var editorNodeA = app.FocusedNode as EditorNode; - Assert.NotNull(editorNodeA); + Assert.IsNotNull(editorNodeA); var scrollAfterMove = editorNodeA!.ScrollOffset; // Scroll should have moved from the default (1) after 10 down-arrows - Assert.True(scrollAfterMove >= 1, $"Expected scroll > 0, got {scrollAfterMove}"); + Assert.IsGreaterThanOrEqualTo(1, scrollAfterMove, $"Expected scroll > 0, got {scrollAfterMove}"); // Switch to a different method via tree var methodB = _state.Analyzer.MethodDefs.First(m => m.Token != methodA.Token && m.Rva > 0); @@ -304,9 +313,9 @@ await auto.WaitUntilAsync(_ => _state.IlEditorMethod?.Token == methodA.Token, // The visible EditorNode should be the same cached node with preserved scroll var restoredEditorNode = app.Focusables.OfType().FirstOrDefault(); - Assert.NotNull(restoredEditorNode); - Assert.Same(editorNodeA, restoredEditorNode); - Assert.Equal(scrollAfterMove, restoredEditorNode!.ScrollOffset); + Assert.IsNotNull(restoredEditorNode); + Assert.AreSame(editorNodeA, restoredEditorNode); + Assert.AreEqual(scrollAfterMove, restoredEditorNode!.ScrollOffset); _cts!.Cancel(); await runTask; @@ -316,10 +325,11 @@ await auto.WaitUntilAsync(_ => _state.IlEditorMethod?.Token == methodA.Token, /// When search n/N switches to a different method, the editor must show the new /// method's IL and focus must be on the visible editor, not on a hidden cached one. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SearchNavigateToMatch_FocusesVisibleEditor() { - var (terminal, app, ct) = CreateDotsiderApp(samples.RichLibraryDll); + var (terminal, app, ct) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -344,7 +354,7 @@ public async Task SearchNavigateToMatch_FocusesVisibleEditor() // Focus must be on an EditorNode that's in the focus ring (visible, not hidden) var focused = app.FocusedNode; - Assert.IsType(focused); + Assert.IsExactInstanceOfType(focused); // The focused EditorNode must be in Focusables (not hidden in ResponsiveWidget) Assert.Contains(focused, app.Focusables); @@ -356,10 +366,11 @@ public async Task SearchNavigateToMatch_FocusesVisibleEditor() /// /// The field pane must stabilize after the first render — not recreate EditorState every frame. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task FieldPane_StableAcrossFrames() { - var (terminal, app, ct) = CreateDotsiderApp(samples.RichLibraryDll); + var (terminal, app, ct) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -377,14 +388,14 @@ public async Task FieldPane_StableAcrossFrames() // Capture the editor key after first render var keyAfterFirstRender = _state.IlEditorKey; - Assert.NotNull(keyAfterFirstRender); + Assert.IsNotNull(keyAfterFirstRender); // Force another render _state.App.Invalidate(); await auto.WaitAsync(TimeSpan.FromMilliseconds(200), ct: ct); // Editor key should be the same (staleness guard prevents recreation) - Assert.Same(keyAfterFirstRender, _state.IlEditorKey); + Assert.AreSame(keyAfterFirstRender, _state.IlEditorKey); _cts!.Cancel(); await runTask; @@ -399,10 +410,11 @@ public async Task FieldPane_StableAcrossFrames() /// in between, so the render loop cannot tick and re-capture the panel before the /// check sees the cleared field. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ResetViewState_ClearsAllLifecycleProperties() { - var (terminal, app, ct) = CreateDotsiderApp(samples.RichLibraryDll); + var (terminal, app, ct) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -422,9 +434,9 @@ await auto.WaitUntilAsync(_ => _state.IlEditorMethod?.Token == methodB.Token, description: "method B loaded"); // Verify state is populated before reset - Assert.NotNull(_state.IlEditorKey); - Assert.True(_state.IlEditorKeyCache.Count > 0); - Assert.True(_state.IlCachedEditors.Count > 0); + Assert.IsNotNull(_state.IlEditorKey); + Assert.IsGreaterThan(0, _state.IlEditorKeyCache.Count); + Assert.IsGreaterThan(0, _state.IlCachedEditors.Count); // Stop the render loop before mutating state so no concurrent render can // re-populate IlScrollPanelNode (or any other field re-captured per render) @@ -436,17 +448,17 @@ await auto.WaitUntilAsync(_ => _state.IlEditorMethod?.Token == methodB.Token, _state.IlTreeScrollOffset = 7; // PushAssemblyDirect calls ResetViewState (same path as CommitAnalyzer) - using var otherAnalyzer = new AssemblyAnalyzer(samples.HelloWorldDll); + using var otherAnalyzer = new AssemblyAnalyzer(Samples.HelloWorldDll); _state.PushAssemblyDirect(otherAnalyzer); // All lifecycle properties must be cleared - Assert.Null(_state.IlEditorKey); - Assert.Null(_state.IlEditorField); - Assert.Null(_state.IlScrollPanelNode); - Assert.False(_state.IlScrollSelectionIntoViewPending); - Assert.Equal(0, _state.IlTreeScrollOffset); - Assert.Empty(_state.IlEditorKeyCache); - Assert.Empty(_state.IlCachedEditors); + Assert.IsNull(_state.IlEditorKey); + Assert.IsNull(_state.IlEditorField); + Assert.IsNull(_state.IlScrollPanelNode); + Assert.IsFalse(_state.IlScrollSelectionIntoViewPending); + Assert.AreEqual(0, _state.IlTreeScrollOffset); + Assert.IsEmpty(_state.IlEditorKeyCache); + Assert.IsEmpty(_state.IlCachedEditors); } /// @@ -454,12 +466,12 @@ await auto.WaitUntilAsync(_ => _state.IlEditorMethod?.Token == methodB.Token, /// public void Dispose() { + GC.SuppressFinalize(this); _cts?.Cancel(); _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); _cts?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/IlEditorWordSelectionTests.cs b/tests/Dotsider.Tests/IlEditorWordSelectionTests.cs index 67ddbb33..a46641c0 100644 --- a/tests/Dotsider.Tests/IlEditorWordSelectionTests.cs +++ b/tests/Dotsider.Tests/IlEditorWordSelectionTests.cs @@ -10,6 +10,7 @@ namespace Dotsider.Tests; /// (periods and punctuation are NOT included in word selection) and /// that Shift+Arrow keyboard selection is not blocked at word boundaries. /// +[TestClass] public class IlEditorWordSelectionTests { /// @@ -36,22 +37,24 @@ private static EditorState SelectWord(string content, int offset) /// /// Verifies select word at dotted name cursor on last word char. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SelectWordAt_DottedName_CursorOnLastWordChar() { // "System.Runtime" — double-click on "System" should place cursor on 'm' (offset 5), // not on '.' (offset 6). var state = SelectWord("System.Runtime", 0); - Assert.Equal(5, state.Cursor.Position.Value); // 'm' in "System" + Assert.AreEqual(5, state.Cursor.Position.Value); // 'm' in "System" var text = state.Document.GetText(); - Assert.Equal('m', text[state.Cursor.Position.Value]); + Assert.AreEqual('m', text[state.Cursor.Position.Value]); } /// /// Verifies select word at dotted name yank copies full word. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SelectWordAt_DottedName_YankCopiesFullWord() { // After double-click + one-shot, yank must copy the full word "System" @@ -66,13 +69,14 @@ public void SelectWordAt_DottedName_YankCopiesFullWord() var yankRange = new DocumentRange(range.Start, yankEnd); var yanked = state.Document.GetText(yankRange); - Assert.Equal("System", yanked); + Assert.AreEqual("System", yanked); } /// /// Verifies select word at dotted name yank cursor on last char. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SelectWordAt_DottedName_YankCursorOnLastChar() { // After yanking "System", cursor should collapse to 'm' (offset 5), not 'e' (offset 4). @@ -84,14 +88,15 @@ public void SelectWordAt_DottedName_YankCursorOnLastChar() state.Document.Length)); var lastChar = new DocumentOffset(Math.Max(0, yankEnd.Value - 1)); - Assert.Equal(5, lastChar.Value); // 'm' in "System" - Assert.Equal('m', state.Document.GetText()[lastChar.Value]); + Assert.AreEqual(5, lastChar.Value); // 'm' in "System" + Assert.AreEqual('m', state.Document.GetText()[lastChar.Value]); } /// /// Verifies select word at dotted name second segment. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SelectWordAt_DottedName_SecondSegment() { var state = SelectWord("System.Runtime", 7); @@ -103,20 +108,22 @@ public void SelectWordAt_DottedName_SecondSegment() /// /// Verifies select word at middle of word. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SelectWordAt_MiddleOfWord() { var state = SelectWord("System.Runtime", 3); var cursorPos = state.Document.OffsetToPosition(state.Cursor.Position); var lineText = state.Document.GetLineText(cursorPos.Line); - Assert.NotEqual('.', lineText[cursorPos.Column - 1]); + Assert.AreNotEqual('.', lineText[cursorPos.Column - 1]); } /// /// Verifies select word at il instruction stops at colon. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SelectWordAt_IlInstruction_StopsAtColon() { var state = SelectWord( @@ -129,20 +136,22 @@ public void SelectWordAt_IlInstruction_StopsAtColon() /// /// Verifies select word at on period cursor not on period. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SelectWordAt_OnPeriod_CursorNotOnPeriod() { var state = SelectWord("System.Runtime", 6); var cursorPos = state.Document.OffsetToPosition(state.Cursor.Position); var lineText = state.Document.GetLineText(cursorPos.Line); - Assert.NotEqual('.', lineText[cursorPos.Column - 1]); + Assert.AreNotEqual('.', lineText[cursorPos.Column - 1]); } /// /// Verifies select word at cursor never on period after dotted selection. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SelectWordAt_CursorNeverOnPeriod_AfterDottedSelection() { var doc = new Hex1bDocument("System.Runtime.CompilerServices"); @@ -166,7 +175,7 @@ public void SelectWordAt_CursorNeverOnPeriod_AfterDottedSelection() { var lineText = doc.GetLineText(doc.OffsetToPosition(pos).Line); var col = doc.OffsetToPosition(pos).Column - 1; - Assert.NotEqual('.', lineText[col]); + Assert.AreNotEqual('.', lineText[col]); } } } @@ -174,7 +183,8 @@ public void SelectWordAt_CursorNeverOnPeriod_AfterDottedSelection() /// /// Verifies shift arrow selection crosses word boundary. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ShiftArrow_SelectionCrossesWordBoundary() { // "System.Runtime" — Shift+Arrow from offset 0 should be able to select @@ -201,18 +211,19 @@ public void ShiftArrow_SelectionCrossesWordBoundary() IlInspectorView.AdjustWordSelectionCursorOneShot(state, ref prevAnchor, ref prevPosition); // Cursor must NOT be pulled back — it should stay where we put it. - Assert.Equal(i, state.Cursor.Position.Value); + Assert.AreEqual(i, state.Cursor.Position.Value); } // Final selection should span from 0 to 13, including the period. var selected = doc.GetText(state.Cursor.SelectionRange); - Assert.Equal("System.Runtim", selected); + Assert.AreEqual("System.Runtim", selected); } /// /// Verifies one shot does not re fire on subsequent frames. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void OneShot_DoesNotReFireOnSubsequentFrames() { // After the one-shot fires, repeated calls with the same state should not @@ -231,13 +242,13 @@ public void OneShot_DoesNotReFireOnSubsequentFrames() IlInspectorView.AdjustWordSelectionCursorOneShot(state, ref prevAnchor, ref prevPosition); var afterFirst = state.Cursor.Position.Value; - Assert.Equal(5, afterFirst); // 'm' in "System" + Assert.AreEqual(5, afterFirst); // 'm' in "System" // Simulate several more render frames with no user input. for (var i = 0; i < 5; i++) { IlInspectorView.AdjustWordSelectionCursorOneShot(state, ref prevAnchor, ref prevPosition); - Assert.Equal(afterFirst, state.Cursor.Position.Value); + Assert.AreEqual(afterFirst, state.Cursor.Position.Value); } } } diff --git a/tests/Dotsider.Tests/IlGoToDefinitionTests.cs b/tests/Dotsider.Tests/IlGoToDefinitionTests.cs index df2da56c..5ec8c7f4 100644 --- a/tests/Dotsider.Tests/IlGoToDefinitionTests.cs +++ b/tests/Dotsider.Tests/IlGoToDefinitionTests.cs @@ -14,9 +14,11 @@ namespace Dotsider.Tests; /// /// Tests for Il Go To Definition. /// -[Collection("SampleAssemblies")] -public sealed class IlGoToDefinitionTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public sealed class IlGoToDefinitionTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _hex1bApp; @@ -34,7 +36,7 @@ public sealed class IlGoToDefinitionTests(SampleAssemblyFixture samples) : IDisp _hex1bApp = new Hex1bApp( ctx => { - _state ??= new DotsiderState(_hex1bApp!, samples.RichLibraryDll) + _state ??= new DotsiderState(_hex1bApp!, Samples.RichLibraryDll) { CurrentTab = TabId.IlInspector }; @@ -80,7 +82,7 @@ private async Task NavigateToCallLocalMethodAndFocusCallLine( const string callText = "call RichLibrary.IlNavigationFixture::LocalTarget"; var text = _state!.IlEditorState!.Document.GetText(); var callOffset = text.IndexOf(callText, StringComparison.Ordinal); - Assert.True(callOffset >= 0, $"Expected rendered IL to contain '{callText}'."); + Assert.IsGreaterThanOrEqualTo(0, callOffset, $"Expected rendered IL to contain '{callText}'."); await SetIlCursorAsync(auto, callOffset, "cursor moved to local call instruction"); @@ -115,13 +117,13 @@ private async Task SetIlCursorOnInstructionAsync( var marker = $"IL_{instruction.Offset:X4}:"; var text = _state!.IlEditorState!.Document.GetText(); var offset = text.IndexOf(marker, StringComparison.Ordinal); - Assert.True(offset >= 0, $"Expected rendered IL to contain '{marker}'."); + Assert.IsGreaterThanOrEqualTo(0, offset, $"Expected rendered IL to contain '{marker}'."); await SetIlCursorAsync(auto, offset, $"cursor moved to {marker}"); var instAtCursor = IlNavigationHelper.GetInstructionAtCursor( _state.IlEditorState!, _state.IlInstructions!, _state.IlHeaderLineCount); - Assert.NotNull(instAtCursor); - Assert.Equal(instruction.Offset, instAtCursor!.Offset); + Assert.IsNotNull(instAtCursor); + Assert.AreEqual(instruction.Offset, instAtCursor!.Offset); } /// @@ -138,49 +140,51 @@ public void Dispose() /// /// Verifies resolve method def returns local method. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Resolve_MethodDef_ReturnsLocalMethod() { - using var analyzer = new AssemblyAnalyzer(samples.RichLibraryDll); + using var analyzer = new AssemblyAnalyzer(Samples.RichLibraryDll); var dis = new IlDisassembler(analyzer); var method = analyzer.MethodDefs.First(m => m.Name == "CallLocalMethod" && m.DeclaringType.Contains("IlNavigationFixture")); var callInst = dis.Disassemble(method).First(i => i.OpCode == "call" && i.MetadataToken is not null); var target = IlNavigationResolver.Resolve(analyzer, callInst.MetadataToken!.Value); - var localMethod = Assert.IsType(target); - Assert.Equal("LocalTarget", localMethod.Method.Name); + var localMethod = Assert.IsExactInstanceOfType(target); + Assert.AreEqual("LocalTarget", localMethod.Method.Name); } /// /// Verifies resolve field def returns local field. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Resolve_FieldDef_ReturnsLocalField() { - using var analyzer = new AssemblyAnalyzer(samples.RichLibraryDll); + using var analyzer = new AssemblyAnalyzer(Samples.RichLibraryDll); var dis = new IlDisassembler(analyzer); var method = analyzer.MethodDefs.First(m => m.Name == "ReadInstanceField" && m.DeclaringType.Contains("IlNavigationFixture")); var fieldInst = dis.Disassemble(method).First(i => i.OpCode == "ldfld" && i.MetadataToken is not null); - Assert.Equal("_counter", Assert.IsType( + Assert.AreEqual("_counter", Assert.IsExactInstanceOfType( IlNavigationResolver.Resolve(analyzer, fieldInst.MetadataToken!.Value)).Field.Name); } /// /// Verifies disassemble with text header and instruction counts match. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DisassembleWithText_HeaderAndInstructionCountsMatch() { - using var analyzer = new AssemblyAnalyzer(samples.RichLibraryDll); + using var analyzer = new AssemblyAnalyzer(Samples.RichLibraryDll); var dis = new IlDisassembler(analyzer); var method = analyzer.MethodDefs.First(m => m.Name == "CallLocalMethod" && m.DeclaringType.Contains("IlNavigationFixture")); var result = dis.DisassembleWithText(method); - Assert.NotNull(result); + Assert.IsNotNull(result); var sequenceCommentCount = result.Value.Instructions.Count(i => i.SequenceStartLine is not null); - Assert.Equal(result.Value.HeaderLineCount + result.Value.Instructions.Count + sequenceCommentCount, - result.Value.Text.Split('\n').Length); + Assert.HasCount(result.Value.HeaderLineCount + result.Value.Instructions.Count + sequenceCommentCount, result.Value.Text.Split('\n')); } // --- Full end-to-end UI tests --- @@ -188,10 +192,11 @@ public void DisassembleWithText_HeaderAndInstructionCountsMatch() /// /// Verifies go to def enter screen shows target method il. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GoToDef_Enter_ScreenShowsTargetMethodIL() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var (terminal, app) = CreateDotsiderApp(); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -208,7 +213,7 @@ public async Task GoToDef_Enter_ScreenShowsTargetMethodIL() await auto.WaitUntilNoTextAsync("// Method: RichLibrary.IlNavigationFixture::CallLocalMethod"); // STATE: IlBackStack should have one entry - Assert.Single(_state!.IlBackStack); + Assert.ContainsSingle(_state!.IlBackStack); // HINTS BAR: should show Esc: Back await auto.WaitUntilTextAsync("Esc: Back"); @@ -220,10 +225,11 @@ public async Task GoToDef_Enter_ScreenShowsTargetMethodIL() /// /// Verifies esc back screen restores original method il. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task EscBack_ScreenRestoresOriginalMethodIL() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var (terminal, app) = CreateDotsiderApp(); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -247,10 +253,11 @@ public async Task EscBack_ScreenRestoresOriginalMethodIL() await auto.WaitUntilNoTextAsync("// Method: RichLibrary.IlNavigationFixture::LocalTarget"); // STATE: IlBackStack should be empty - Assert.Empty(_state!.IlBackStack); + Assert.IsEmpty(_state!.IlBackStack); // STATE: selected method should be CallLocalMethod - Assert.Equal("CallLocalMethod", _state.IlSelectedMethod?.Name); + Assert.IsNotNull(_state.IlSelectedMethod); + Assert.AreEqual("CallLocalMethod", _state.IlSelectedMethod.Name); cts.Cancel(); await runTask; @@ -259,16 +266,17 @@ public async Task EscBack_ScreenRestoresOriginalMethodIL() /// /// Verifies esc back cursor position restored exactly. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task EscBack_CursorPositionRestoredExactly() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var (terminal, app) = CreateDotsiderApp(); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); var cursorBeforeNav = await NavigateToCallLocalMethodAndFocusCallLine(auto, cts.Token); - Assert.True(cursorBeforeNav > 0, "Cursor should be past the header lines"); + Assert.IsGreaterThan(0, cursorBeforeNav, "Cursor should be past the header lines"); // Go to definition await auto.EnterAsync(cts.Token); @@ -281,7 +289,7 @@ public async Task EscBack_CursorPositionRestoredExactly() // CURSOR: must be in the instruction area (past header lines), proving // the cursor was restored to the IL bytecode region, not reset to line 1. var cursorAfterBack = _state!.IlEditorState?.Cursor.Position.Value ?? -1; - Assert.True(cursorAfterBack > 0, "Cursor should be restored past header lines"); + Assert.IsGreaterThan(0, cursorAfterBack, "Cursor should be restored past header lines"); var text = _state.IlEditorState!.Document.GetText(); int LineOf(int offset) @@ -293,8 +301,7 @@ int LineOf(int offset) } var cursorLine = LineOf(cursorAfterBack); - Assert.True(cursorLine > _state.IlHeaderLineCount, - $"Cursor should be in the instruction area (line {cursorLine}) " + + Assert.IsGreaterThan(_state.IlHeaderLineCount, cursorLine, $"Cursor should be in the instruction area (line {cursorLine}) " + $"but header has {_state.IlHeaderLineCount} lines"); cts.Cancel(); @@ -304,10 +311,11 @@ int LineOf(int offset) /// /// Verifies esc back scroll works after restore. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task EscBack_ScrollWorksAfterRestore() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var (terminal, app) = CreateDotsiderApp(); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -345,7 +353,7 @@ await auto.WaitUntilAsync( description: "Up moved editor cursor"); var cursorAfterUp = _state.IlEditorState?.Cursor.Position.Value ?? -1; - Assert.NotEqual(cursorAfterScroll, cursorAfterUp); + Assert.AreNotEqual(cursorAfterScroll, cursorAfterUp); // SCREEN: IL bytecode must still be visible after scrolling await auto.WaitUntilTextAsync("call RichLibrary.IlNavigationFixture::LocalTarget"); @@ -357,10 +365,11 @@ await auto.WaitUntilAsync( /// /// Verifies esc back tree state restored. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task EscBack_TreeStateRestored() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var (terminal, app) = CreateDotsiderApp(); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -382,13 +391,13 @@ public async Task EscBack_TreeStateRestored() // TREE STATE: expansion state must match what was saved foreach (var (key, value) in treeBefore) { - Assert.True(_state.IlTreeExpansionState.TryGetValue(key, out var restored), + Assert.IsTrue(_state.IlTreeExpansionState.TryGetValue(key, out var restored), $"Tree key '{key}' missing after restore"); - Assert.Equal(value, restored); + Assert.AreEqual(value, restored); } // TREE STATE: focused key must be restored - Assert.Equal(focusedKeyBefore, _state.IlFocusedTreeKey); + Assert.AreEqual(focusedKeyBefore, _state.IlFocusedTreeKey); cts.Cancel(); await runTask; @@ -396,10 +405,11 @@ public async Task EscBack_TreeStateRestored() /// /// Verifies diagnostic escape handler fires state changes. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Diagnostic_EscapeHandlerFires_StateChanges() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var (terminal, app) = CreateDotsiderApp(); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -411,8 +421,9 @@ public async Task Diagnostic_EscapeHandlerFires_StateChanges() await auto.WaitUntilTextAsync("// Method: RichLibrary.IlNavigationFixture::LocalTarget"); // Verify back stack has entry - Assert.Single(_state!.IlBackStack); - Assert.Equal("LocalTarget", _state.IlSelectedMethod?.Name); + Assert.ContainsSingle(_state!.IlBackStack); + Assert.IsNotNull(_state.IlSelectedMethod); + Assert.AreEqual("LocalTarget", _state.IlSelectedMethod.Name); // Press Escape await auto.EscapeAsync(cts.Token); @@ -424,9 +435,9 @@ public async Task Diagnostic_EscapeHandlerFires_StateChanges() var methodName = _state.IlSelectedMethod?.Name; // Diagnostic output - Assert.True(backStackEmpty, $"IlBackStack should be empty after Esc but has {_state.IlBackStack.Count} entries. " + + Assert.IsTrue(backStackEmpty, $"IlBackStack should be empty after Esc but has {_state.IlBackStack.Count} entries. " + $"Method is '{methodName}'. This means the Escape handler did NOT fire."); - Assert.Equal("CallLocalMethod", methodName); + Assert.AreEqual("CallLocalMethod", methodName); cts.Cancel(); await runTask; @@ -435,13 +446,14 @@ public async Task Diagnostic_EscapeHandlerFires_StateChanges() /// /// Verifies cross assembly back stack survives reset view state. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void CrossAssembly_BackStackSurvivesResetViewState() { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.IlInspector; // Select CallExternal and set up editor state @@ -449,7 +461,7 @@ public void CrossAssembly_BackStackSurvivesResetViewState() m.Name == "CallExternal" && m.DeclaringType.Contains("IlNavigationFixture")); state.IlSelectedMethod = method; var result = state.IlDisassembler!.DisassembleWithText(method); - Assert.NotNull(result); + Assert.IsNotNull(result); var doc = new Hex1b.Documents.Hex1bDocument(result.Value.Text); state.IlEditorState = new EditorState(doc) { IsReadOnly = true }; state.IlEditorMethod = method; @@ -458,21 +470,21 @@ public void CrossAssembly_BackStackSurvivesResetViewState() // Find the call instruction token var callInst = result.Value.Instructions.FirstOrDefault(i => i.OpCode == "call" && i.MetadataToken is not null && i.Operand.Contains("WriteLine")); - Assert.NotNull(callInst); + Assert.IsNotNull(callInst); // Navigate to external method var navigated = state.NavigateToIlDefinition(callInst.MetadataToken!.Value); // Back stack must survive PushAssemblyDirect → ResetViewState - Assert.True(state.IlBackStack.Count > 0, - $"IlBackStack must survive cross-assembly navigation but has {state.IlBackStack.Count} entries"); + Assert.IsGreaterThan(0, state.IlBackStack.Count, $"IlBackStack must survive cross-assembly navigation but has {state.IlBackStack.Count} entries"); // Restore from back entry if (navigated) { var entry = state.IlBackStack.Pop(); state.RestoreFromIlBackEntry(entry); - Assert.Equal("CallExternal", state.IlSelectedMethod?.Name); + Assert.IsNotNull(state.IlSelectedMethod); + Assert.AreEqual("CallExternal", state.IlSelectedMethod.Name); Assert.Contains("RichLibrary", state.Analyzer.FileName); } } @@ -480,20 +492,21 @@ public void CrossAssembly_BackStackSurvivesResetViewState() /// /// Verifies local field go to def clears selected method. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void LocalField_GoToDef_ClearsSelectedMethod() { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.IlInspector; var method = state.Analyzer.MethodDefs.First(m => m.Name == "ReadInstanceField" && m.DeclaringType.Contains("IlNavigationFixture")); state.IlSelectedMethod = method; var result = state.IlDisassembler!.DisassembleWithText(method); - Assert.NotNull(result); + Assert.IsNotNull(result); state.IlEditorState = new EditorState( new Hex1b.Documents.Hex1bDocument(result.Value.Text)) { IsReadOnly = true }; state.IlEditorMethod = method; @@ -506,25 +519,28 @@ public void LocalField_GoToDef_ClearsSelectedMethod() // Field navigation should clear IlSelectedMethod (no method to show) // and focus the declaring type in the tree - Assert.Null(state.IlSelectedMethod); - Assert.Contains("type:", (string?)state.IlFocusedTreeKey); + Assert.IsNull(state.IlSelectedMethod); + Assert.IsNotNull(state.IlFocusedTreeKey); + Assert.Contains("type:", (string)state.IlFocusedTreeKey); // Back should restore var entry = state.IlBackStack.Pop(); state.RestoreFromIlBackEntry(entry); - Assert.Equal("ReadInstanceField", state.IlSelectedMethod?.Name); + Assert.IsNotNull(state.IlSelectedMethod); + Assert.AreEqual("ReadInstanceField", state.IlSelectedMethod.Name); } /// /// Verifies normal push assembly clears il back stack. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NormalPushAssembly_ClearsIlBackStack() { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.IlInspector; // Set up a local go-to-def so IlBackStack has an entry @@ -532,7 +548,7 @@ public void NormalPushAssembly_ClearsIlBackStack() m.Name == "CallLocalMethod" && m.DeclaringType.Contains("IlNavigationFixture")); state.IlSelectedMethod = method; var result = state.IlDisassembler!.DisassembleWithText(method); - Assert.NotNull(result); + Assert.IsNotNull(result); state.IlEditorState = new EditorState( new Hex1b.Documents.Hex1bDocument(result.Value.Text)) { IsReadOnly = true }; state.IlEditorMethod = method; @@ -541,12 +557,12 @@ public void NormalPushAssembly_ClearsIlBackStack() var callInst = result.Value.Instructions.First(i => i.OpCode == "call" && i.MetadataToken is not null); state.NavigateToIlDefinition(callInst.MetadataToken!.Value); - Assert.Single(state.IlBackStack); + Assert.ContainsSingle(state.IlBackStack); // Normal assembly push (dependency navigation) must clear the back stack // because entries reference the old analyzer's state - state.PushAssembly(samples.HelloWorldDll); - Assert.Empty(state.IlBackStack); + state.PushAssembly(Samples.HelloWorldDll); + Assert.IsEmpty(state.IlBackStack); } // --- Issue #159: Esc on IL Inspector loses Size Map back target after gd into external --- @@ -555,24 +571,25 @@ public void NormalPushAssembly_ClearsIlBackStack() /// Verifies the cross-view back target survives a cross-assembly method gd /// round-trip (Size Map → IL Inspector → external method → Esc back). /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void EscBack_FromCrossAssemblyMethodGd_RestoresSizeMapCrossViewBackTarget() { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); var method = state.Analyzer.MethodDefs.First(m => m.Name == "CallExternal" && m.DeclaringType.Contains("IlNavigationFixture")); state.CurrentTab = TabId.SizeMap; state.NavigateToIlMethod(method); - Assert.Equal(TabId.IlInspector, state.CurrentTab); - Assert.Equal((TabId.SizeMap, 0), state.CrossViewBackTarget); + Assert.AreEqual(TabId.IlInspector, state.CurrentTab); + Assert.AreEqual((TabId.SizeMap, 0), state.CrossViewBackTarget); var result = state.IlDisassembler!.DisassembleWithText(method); - Assert.NotNull(result); + Assert.IsNotNull(result); state.IlEditorState = new EditorState( new Hex1b.Documents.Hex1bDocument(result.Value.Text)) { IsReadOnly = true }; state.IlEditorMethod = method; @@ -582,34 +599,35 @@ public void EscBack_FromCrossAssemblyMethodGd_RestoresSizeMapCrossViewBackTarget i.OpCode == "call" && i.MetadataToken is not null && i.Operand.Contains("WriteLine")); var navigated = state.NavigateToIlDefinition(callInst.MetadataToken!.Value); - Assert.True(navigated); - Assert.Single(state.IlBackStack); - Assert.True(state.NavigationStack.Count > 0); - Assert.Null(state.CrossViewBackTarget); + Assert.IsTrue(navigated); + Assert.ContainsSingle(state.IlBackStack); + Assert.IsGreaterThan(0, state.NavigationStack.Count); + Assert.IsNull(state.CrossViewBackTarget); var entry = state.IlBackStack.Pop(); state.RestoreFromIlBackEntry(entry); - Assert.Equal(TabId.IlInspector, state.CurrentTab); - Assert.Empty(state.NavigationStack); - Assert.Empty(state.IlBackStack); - Assert.Equal((TabId.SizeMap, 0), state.CrossViewBackTarget); + Assert.AreEqual(TabId.IlInspector, state.CurrentTab); + Assert.IsEmpty(state.NavigationStack); + Assert.IsEmpty(state.IlBackStack); + Assert.AreEqual((TabId.SizeMap, 0), state.CrossViewBackTarget); state.NavigateBack(); - Assert.Equal(TabId.SizeMap, state.CurrentTab); - Assert.Null(state.CrossViewBackTarget); + Assert.AreEqual(TabId.SizeMap, state.CurrentTab); + Assert.IsNull(state.CrossViewBackTarget); } /// /// Verifies the cross-view back target survives a cross-assembly field gd round-trip. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void EscBack_FromCrossAssemblyFieldGd_RestoresSizeMapCrossViewBackTarget() { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); // GetStringEmpty has body `ldsfld string.Empty` — external FieldRef var method = state.Analyzer.MethodDefs.First(m => @@ -617,11 +635,11 @@ public void EscBack_FromCrossAssemblyFieldGd_RestoresSizeMapCrossViewBackTarget( state.CurrentTab = TabId.SizeMap; state.NavigateToIlMethod(method); - Assert.Equal(TabId.IlInspector, state.CurrentTab); - Assert.Equal((TabId.SizeMap, 0), state.CrossViewBackTarget); + Assert.AreEqual(TabId.IlInspector, state.CurrentTab); + Assert.AreEqual((TabId.SizeMap, 0), state.CrossViewBackTarget); var result = state.IlDisassembler!.DisassembleWithText(method); - Assert.NotNull(result); + Assert.IsNotNull(result); state.IlEditorState = new EditorState( new Hex1b.Documents.Hex1bDocument(result.Value.Text)) { IsReadOnly = true }; state.IlEditorMethod = method; @@ -631,34 +649,35 @@ public void EscBack_FromCrossAssemblyFieldGd_RestoresSizeMapCrossViewBackTarget( i.OpCode == "ldsfld" && i.MetadataToken is not null); var navigated = state.NavigateToIlDefinition(ldsInst.MetadataToken!.Value); - Assert.True(navigated); - Assert.Single(state.IlBackStack); - Assert.True(state.NavigationStack.Count > 0); - Assert.Null(state.CrossViewBackTarget); + Assert.IsTrue(navigated); + Assert.ContainsSingle(state.IlBackStack); + Assert.IsGreaterThan(0, state.NavigationStack.Count); + Assert.IsNull(state.CrossViewBackTarget); var entry = state.IlBackStack.Pop(); state.RestoreFromIlBackEntry(entry); - Assert.Equal(TabId.IlInspector, state.CurrentTab); - Assert.Empty(state.NavigationStack); - Assert.Empty(state.IlBackStack); - Assert.Equal((TabId.SizeMap, 0), state.CrossViewBackTarget); + Assert.AreEqual(TabId.IlInspector, state.CurrentTab); + Assert.IsEmpty(state.NavigationStack); + Assert.IsEmpty(state.IlBackStack); + Assert.AreEqual((TabId.SizeMap, 0), state.CrossViewBackTarget); state.NavigateBack(); - Assert.Equal(TabId.SizeMap, state.CurrentTab); - Assert.Null(state.CrossViewBackTarget); + Assert.AreEqual(TabId.SizeMap, state.CurrentTab); + Assert.IsNull(state.CrossViewBackTarget); } /// /// Verifies the cross-view back target survives a cross-assembly type gd round-trip. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void EscBack_FromCrossAssemblyTypeGd_RestoresSizeMapCrossViewBackTarget() { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); // CastToExternalStream has body `castclass System.IO.Stream` — external TypeRef var method = state.Analyzer.MethodDefs.First(m => @@ -666,11 +685,11 @@ public void EscBack_FromCrossAssemblyTypeGd_RestoresSizeMapCrossViewBackTarget() state.CurrentTab = TabId.SizeMap; state.NavigateToIlMethod(method); - Assert.Equal(TabId.IlInspector, state.CurrentTab); - Assert.Equal((TabId.SizeMap, 0), state.CrossViewBackTarget); + Assert.AreEqual(TabId.IlInspector, state.CurrentTab); + Assert.AreEqual((TabId.SizeMap, 0), state.CrossViewBackTarget); var result = state.IlDisassembler!.DisassembleWithText(method); - Assert.NotNull(result); + Assert.IsNotNull(result); state.IlEditorState = new EditorState( new Hex1b.Documents.Hex1bDocument(result.Value.Text)) { IsReadOnly = true }; state.IlEditorMethod = method; @@ -680,22 +699,22 @@ public void EscBack_FromCrossAssemblyTypeGd_RestoresSizeMapCrossViewBackTarget() i.OpCode == "castclass" && i.MetadataToken is not null); var navigated = state.NavigateToIlDefinition(castInst.MetadataToken!.Value); - Assert.True(navigated); - Assert.Single(state.IlBackStack); - Assert.True(state.NavigationStack.Count > 0); - Assert.Null(state.CrossViewBackTarget); + Assert.IsTrue(navigated); + Assert.ContainsSingle(state.IlBackStack); + Assert.IsGreaterThan(0, state.NavigationStack.Count); + Assert.IsNull(state.CrossViewBackTarget); var entry = state.IlBackStack.Pop(); state.RestoreFromIlBackEntry(entry); - Assert.Equal(TabId.IlInspector, state.CurrentTab); - Assert.Empty(state.NavigationStack); - Assert.Empty(state.IlBackStack); - Assert.Equal((TabId.SizeMap, 0), state.CrossViewBackTarget); + Assert.AreEqual(TabId.IlInspector, state.CurrentTab); + Assert.IsEmpty(state.NavigationStack); + Assert.IsEmpty(state.IlBackStack); + Assert.AreEqual((TabId.SizeMap, 0), state.CrossViewBackTarget); state.NavigateBack(); - Assert.Equal(TabId.SizeMap, state.CurrentTab); - Assert.Null(state.CrossViewBackTarget); + Assert.AreEqual(TabId.SizeMap, state.CurrentTab); + Assert.IsNull(state.CrossViewBackTarget); } /// @@ -703,24 +722,25 @@ public void EscBack_FromCrossAssemblyTypeGd_RestoresSizeMapCrossViewBackTarget() /// back target without going through the snapshot/restore round-trip, /// because the local path never calls PushAssemblyDirect → ResetViewState. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void EscBack_FromLocalGd_PreservesSizeMapCrossViewBackTarget() { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); var method = state.Analyzer.MethodDefs.First(m => m.Name == "CallLocalMethod" && m.DeclaringType.Contains("IlNavigationFixture")); state.CurrentTab = TabId.SizeMap; state.NavigateToIlMethod(method); - Assert.Equal(TabId.IlInspector, state.CurrentTab); - Assert.Equal((TabId.SizeMap, 0), state.CrossViewBackTarget); + Assert.AreEqual(TabId.IlInspector, state.CurrentTab); + Assert.AreEqual((TabId.SizeMap, 0), state.CrossViewBackTarget); var result = state.IlDisassembler!.DisassembleWithText(method); - Assert.NotNull(result); + Assert.IsNotNull(result); state.IlEditorState = new EditorState( new Hex1b.Documents.Hex1bDocument(result.Value.Text)) { IsReadOnly = true }; state.IlEditorMethod = method; @@ -730,16 +750,16 @@ public void EscBack_FromLocalGd_PreservesSizeMapCrossViewBackTarget() i.OpCode == "call" && i.MetadataToken is not null && i.Operand.Contains("LocalTarget")); var navigated = state.NavigateToIlDefinition(callInst.MetadataToken!.Value); - Assert.True(navigated); - Assert.Single(state.IlBackStack); - Assert.Empty(state.NavigationStack); - Assert.Equal((TabId.SizeMap, 0), state.CrossViewBackTarget); + Assert.IsTrue(navigated); + Assert.ContainsSingle(state.IlBackStack); + Assert.IsEmpty(state.NavigationStack); + Assert.AreEqual((TabId.SizeMap, 0), state.CrossViewBackTarget); state.RestoreFromIlBackEntry(state.IlBackStack.Pop()); - Assert.Equal((TabId.SizeMap, 0), state.CrossViewBackTarget); + Assert.AreEqual((TabId.SizeMap, 0), state.CrossViewBackTarget); state.NavigateBack(); - Assert.Equal(TabId.SizeMap, state.CurrentTab); + Assert.AreEqual(TabId.SizeMap, state.CurrentTab); } /// @@ -747,10 +767,11 @@ public void EscBack_FromLocalGd_PreservesSizeMapCrossViewBackTarget() /// and confirm two REAL Esc presses return to Size Map. Pre-fix the second /// Esc binding de-registers and the user is stuck on IL Inspector. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task EscBack_FromSizeMapToExternalCall_TwoRealEscapesReturnToSizeMap() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var (terminal, app) = CreateDotsiderApp(); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -765,7 +786,7 @@ public async Task EscBack_FromSizeMapToExternalCall_TwoRealEscapesReturnToSizeMa m.Name == "CallExternal" && m.DeclaringType.Contains("IlNavigationFixture")); _state.CurrentTab = TabId.SizeMap; _state.NavigateToIlMethod(callExternal); - Assert.Equal((TabId.SizeMap, 0), _state.CrossViewBackTarget); + Assert.AreEqual((TabId.SizeMap, 0), _state.CrossViewBackTarget); await auto.WaitUntilTextAsync("// Method: RichLibrary.IlNavigationFixture::CallExternal"); await auto.WaitUntilTextAsync("call System.Console::WriteLine"); @@ -789,13 +810,13 @@ await auto.WaitUntilAsync(_ => var instructions = _state!.IlInstructions!.ToList(); var callIndex = instructions.FindIndex(i => i.OpCode == "call" && i.MetadataToken is not null && i.Operand.Contains("WriteLine")); - Assert.True(callIndex >= 0, "WriteLine call instruction must exist in CallExternal body"); + Assert.IsGreaterThanOrEqualTo(0, callIndex, "WriteLine call instruction must exist in CallExternal body"); await SetIlCursorOnInstructionAsync(auto, instructions[callIndex]); var instAtCursor = IlNavigationHelper.GetInstructionAtCursor( _state.IlEditorState!, _state.IlInstructions!, _state.IlHeaderLineCount); - Assert.NotNull(instAtCursor); - Assert.Equal("call", instAtCursor!.OpCode); + Assert.IsNotNull(instAtCursor); + Assert.AreEqual("call", instAtCursor!.OpCode); Assert.Contains("WriteLine", instAtCursor.Operand); // Real gd via Enter — crosses into System.Console.dll. @@ -804,9 +825,9 @@ await auto.WaitUntilAsync(_ => // The IlDisassembler emits "// Method: System.Console::WriteLine" only // in the destination's IL header — unique landing marker. await auto.WaitUntilTextAsync("// Method: System.Console::WriteLine"); - Assert.True(_state.NavigationStack.Count > 0); - Assert.Single(_state.IlBackStack); - Assert.Null(_state.CrossViewBackTarget); + Assert.IsGreaterThan(0, _state.NavigationStack.Count); + Assert.ContainsSingle(_state.IlBackStack); + Assert.IsNull(_state.CrossViewBackTarget); // First REAL Esc — pops IL back entry, returns to CallExternal IL. await auto.EscapeAsync(cts.Token); @@ -816,16 +837,16 @@ await auto.WaitUntilAsync(_ => // binding is registered for the second press. Pre-fix all three back // signals are zero/null and the binding de-registers. await auto.WaitUntilTextAsync("Esc: Back"); - Assert.Equal((TabId.SizeMap, 0), _state.CrossViewBackTarget); - Assert.Equal(TabId.IlInspector, _state.CurrentTab); - Assert.Empty(_state.IlBackStack); - Assert.Empty(_state.NavigationStack); + Assert.AreEqual((TabId.SizeMap, 0), _state.CrossViewBackTarget); + Assert.AreEqual(TabId.IlInspector, _state.CurrentTab); + Assert.IsEmpty(_state.IlBackStack); + Assert.IsEmpty(_state.NavigationStack); // Second REAL Esc — drives cross-view return to Size Map. await auto.EscapeAsync(cts.Token); await auto.WaitUntilTextAsync("Total:"); - Assert.Equal(TabId.SizeMap, _state.CurrentTab); - Assert.Null(_state.CrossViewBackTarget); + Assert.AreEqual(TabId.SizeMap, _state.CurrentTab); + Assert.IsNull(_state.CrossViewBackTarget); cts.Cancel(); await runTask; @@ -846,8 +867,7 @@ private static (SizeNode Root, SizeNode Namespace, SizeNode Type, int MethodChil var typeNode = nsNode.Children.First(c => c.FullPath == method.DeclaringType); var methodIdx = typeNode.Children.ToList().FindIndex(c => c.FullPath == $"{method.DeclaringType}::{method.Name}@0x{method.Token:X8}"); - Assert.True(methodIdx >= 0, - $"Method {method.DeclaringType}::{method.Name} not found in size tree"); + Assert.IsGreaterThanOrEqualTo(0, methodIdx, $"Method {method.DeclaringType}::{method.Name} not found in size tree"); return (root, nsNode, typeNode, methodIdx); } @@ -856,17 +876,18 @@ private static (SizeNode Root, SizeNode Namespace, SizeNode Type, int MethodChil /// selection, search) is restored after a cross-assembly gd round-trip. Parameterized /// across the three external dispatch paths: method, field, type. /// - [Theory(Timeout = 30_000)] - [InlineData("CallExternal", "call", "WriteLine")] - [InlineData("GetStringEmpty", "ldsfld", "")] - [InlineData("CastToExternalStream", "castclass", "")] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow("CallExternal", "call", "WriteLine")] + [DataRow("GetStringEmpty", "ldsfld", "")] + [DataRow("CastToExternalStream", "castclass", "")] public void EscBack_FromCrossAssemblyGd_RestoresFullSizeMapDrillState( string methodName, string opCode, string operandSubstring) { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CachedSizeTree = SizeAnalyzer.BuildSizeTree(state.Analyzer); var origTree = state.CachedSizeTree; @@ -891,12 +912,12 @@ public void EscBack_FromCrossAssemblyGd_RestoresFullSizeMapDrillState( state.CurrentTab = TabId.SizeMap; state.NavigateToIlMethod(method); - Assert.Same(type, state.TreemapCurrentLevel); - Assert.Equal(2, state.TreemapBreadcrumb.Count); - Assert.Equal((TabId.SizeMap, 0), state.CrossViewBackTarget); + Assert.AreSame(type, state.TreemapCurrentLevel); + Assert.HasCount(2, state.TreemapBreadcrumb); + Assert.AreEqual((TabId.SizeMap, 0), state.CrossViewBackTarget); var dis = state.IlDisassembler!.DisassembleWithText(method); - Assert.NotNull(dis); + Assert.IsNotNull(dis); state.IlEditorState = new EditorState( new Hex1b.Documents.Hex1bDocument(dis.Value.Text)) { IsReadOnly = true }; state.IlEditorMethod = method; @@ -905,57 +926,58 @@ public void EscBack_FromCrossAssemblyGd_RestoresFullSizeMapDrillState( var inst = dis.Value.Instructions.First(i => i.OpCode == opCode && i.MetadataToken is not null && (operandSubstring.Length == 0 || i.Operand.Contains(operandSubstring))); - Assert.True(state.NavigateToIlDefinition(inst.MetadataToken!.Value)); + Assert.IsTrue(state.NavigateToIlDefinition(inst.MetadataToken!.Value)); // Mid-flight: ResetViewState wiped everything. - Assert.Null(state.TreemapCurrentLevel); - Assert.Empty(state.TreemapBreadcrumb); - Assert.Null(state.CachedSizeTree); - Assert.False(state.Search[TabId.SizeMap].IsActive); + Assert.IsNull(state.TreemapCurrentLevel); + Assert.IsEmpty(state.TreemapBreadcrumb); + Assert.IsNull(state.CachedSizeTree); + Assert.IsFalse(state.Search[TabId.SizeMap].IsActive); // First Esc — pop the cross-assembly entry and restore. state.RestoreFromIlBackEntry(state.IlBackStack.Pop()); - Assert.Equal((TabId.SizeMap, 0), state.CrossViewBackTarget); - Assert.Same(origTree, state.CachedSizeTree); - Assert.Same(type, state.TreemapCurrentLevel); + Assert.AreEqual((TabId.SizeMap, 0), state.CrossViewBackTarget); + Assert.AreSame(origTree, state.CachedSizeTree); + Assert.AreSame(type, state.TreemapCurrentLevel); var stack = state.TreemapBreadcrumb.ToArray(); - Assert.Equal(2, stack.Length); - Assert.Same(ns, stack[0]); - Assert.Same(root, stack[1]); - Assert.Equal(methodIdx, state.TreemapSelectedIndex); - Assert.Same( + Assert.HasCount(2, stack); + Assert.AreSame(ns, stack[0]); + Assert.AreSame(root, stack[1]); + Assert.AreEqual(methodIdx, state.TreemapSelectedIndex); + Assert.AreSame( type.Children[methodIdx], state.TreemapCurrentLevel!.Children[state.TreemapSelectedIndex]); var s = state.Search[TabId.SizeMap]; - Assert.True(s.IsActive); - Assert.True(s.IsConfirmed); - Assert.Equal("Call", s.Query); - Assert.Equal(2, s.MatchCount); + Assert.IsTrue(s.IsActive); + Assert.IsTrue(s.IsConfirmed); + Assert.AreEqual("Call", s.Query); + Assert.AreEqual(2, s.MatchCount); // Second Esc — back to Size Map at the drilled level. state.NavigateBack(); - Assert.Equal(TabId.SizeMap, state.CurrentTab); - Assert.Same(type, state.TreemapCurrentLevel); + Assert.AreEqual(TabId.SizeMap, state.CurrentTab); + Assert.AreSame(type, state.TreemapCurrentLevel); } /// /// Regression guard: when the user never drilled (TreemapCurrentLevel == null, /// breadcrumb empty), the snapshot/restore round-trip must not invent a breadcrumb. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void EscBack_NoDrill_NullCurrentLevelStaysNull() { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); // No drill: TreemapCurrentLevel left null, breadcrumb empty, no cached tree. - Assert.Null(state.TreemapCurrentLevel); - Assert.Empty(state.TreemapBreadcrumb); - Assert.Null(state.CachedSizeTree); + Assert.IsNull(state.TreemapCurrentLevel); + Assert.IsEmpty(state.TreemapBreadcrumb); + Assert.IsNull(state.CachedSizeTree); var method = state.Analyzer.MethodDefs.First(m => m.Name == "CallExternal" && m.DeclaringType.Contains("IlNavigationFixture")); @@ -969,14 +991,14 @@ public void EscBack_NoDrill_NullCurrentLevelStaysNull() state.IlEditorAnalyzer = state.Analyzer; var callInst = dis.Value.Instructions.First(i => i.OpCode == "call" && i.MetadataToken is not null && i.Operand.Contains("WriteLine")); - Assert.True(state.NavigateToIlDefinition(callInst.MetadataToken!.Value)); + Assert.IsTrue(state.NavigateToIlDefinition(callInst.MetadataToken!.Value)); state.RestoreFromIlBackEntry(state.IlBackStack.Pop()); - Assert.Null(state.TreemapCurrentLevel); - Assert.Empty(state.TreemapBreadcrumb); - Assert.Equal(-1, state.TreemapSelectedIndex); - Assert.False(state.Search[TabId.SizeMap].IsActive); + Assert.IsNull(state.TreemapCurrentLevel); + Assert.IsEmpty(state.TreemapBreadcrumb); + Assert.AreEqual(-1, state.TreemapSelectedIndex); + Assert.IsFalse(state.Search[TabId.SizeMap].IsActive); } /// @@ -985,18 +1007,19 @@ public void EscBack_NoDrill_NullCurrentLevelStaysNull() /// Pre-fix this fails because ResetViewState zeros TreemapCurrentLevel and nothing puts /// the root reference back. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void EscBack_DrilledToRoot_RestoresRootIdentity() { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CachedSizeTree = SizeAnalyzer.BuildSizeTree(state.Analyzer); var origTree = state.CachedSizeTree; state.TreemapCurrentLevel = origTree; // user drilled then popped back to root - Assert.Empty(state.TreemapBreadcrumb); + Assert.IsEmpty(state.TreemapBreadcrumb); var method = state.Analyzer.MethodDefs.First(m => m.Name == "CallExternal" && m.DeclaringType.Contains("IlNavigationFixture")); @@ -1010,13 +1033,13 @@ public void EscBack_DrilledToRoot_RestoresRootIdentity() state.IlEditorAnalyzer = state.Analyzer; var callInst = dis.Value.Instructions.First(i => i.OpCode == "call" && i.MetadataToken is not null && i.Operand.Contains("WriteLine")); - Assert.True(state.NavigateToIlDefinition(callInst.MetadataToken!.Value)); + Assert.IsTrue(state.NavigateToIlDefinition(callInst.MetadataToken!.Value)); state.RestoreFromIlBackEntry(state.IlBackStack.Pop()); - Assert.Same(origTree, state.CachedSizeTree); - Assert.Same(origTree, state.TreemapCurrentLevel); - Assert.Empty(state.TreemapBreadcrumb); + Assert.AreSame(origTree, state.CachedSizeTree); + Assert.AreSame(origTree, state.TreemapCurrentLevel); + Assert.IsEmpty(state.TreemapBreadcrumb); } /// @@ -1024,13 +1047,14 @@ public void EscBack_DrilledToRoot_RestoresRootIdentity() /// the treemap state survives without snapshot/restore. After a local gd round-trip /// the original drill state must still be present (object identity preserved). /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void EscBack_FromLocalGd_PreservesBreadcrumb() { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CachedSizeTree = SizeAnalyzer.BuildSizeTree(state.Analyzer); var origTree = state.CachedSizeTree; @@ -1055,19 +1079,19 @@ public void EscBack_FromLocalGd_PreservesBreadcrumb() state.IlEditorAnalyzer = state.Analyzer; var callInst = dis.Value.Instructions.First(i => i.OpCode == "call" && i.MetadataToken is not null && i.Operand.Contains("LocalTarget")); - Assert.True(state.NavigateToIlDefinition(callInst.MetadataToken!.Value)); + Assert.IsTrue(state.NavigateToIlDefinition(callInst.MetadataToken!.Value)); // Local path: ResetViewState was NEVER called — drill state intact. - Assert.Same(origTree, state.CachedSizeTree); - Assert.Same(type, state.TreemapCurrentLevel); - Assert.Equal(2, state.TreemapBreadcrumb.Count); + Assert.AreSame(origTree, state.CachedSizeTree); + Assert.AreSame(type, state.TreemapCurrentLevel); + Assert.HasCount(2, state.TreemapBreadcrumb); state.RestoreFromIlBackEntry(state.IlBackStack.Pop()); - Assert.Same(origTree, state.CachedSizeTree); - Assert.Same(type, state.TreemapCurrentLevel); - Assert.Equal(2, state.TreemapBreadcrumb.Count); - Assert.Equal(methodIdx, state.TreemapSelectedIndex); + Assert.AreSame(origTree, state.CachedSizeTree); + Assert.AreSame(type, state.TreemapCurrentLevel); + Assert.HasCount(2, state.TreemapBreadcrumb); + Assert.AreEqual(methodIdx, state.TreemapSelectedIndex); } /// @@ -1076,10 +1100,11 @@ public void EscBack_FromLocalGd_PreservesBreadcrumb() /// land back on Size Map at the original drilled type level — proven by waiting /// for the rendered breadcrumb to include the type name. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task EscBack_FromSizeMapDeepDrill_TwoRealEscapesShowBreadcrumb() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var (terminal, app) = CreateDotsiderApp(); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -1105,7 +1130,7 @@ public async Task EscBack_FromSizeMapDeepDrill_TwoRealEscapesShowBreadcrumb() // on the wrong tab and the test wouldn't prove the reopened bug. _state.CurrentTab = TabId.SizeMap; _state.NavigateToIlMethod(callExternal); - Assert.Equal((TabId.SizeMap, 0), _state.CrossViewBackTarget); + Assert.AreEqual((TabId.SizeMap, 0), _state.CrossViewBackTarget); await auto.WaitUntilTextAsync("// Method: RichLibrary.IlNavigationFixture::CallExternal"); @@ -1123,7 +1148,7 @@ await auto.WaitUntilAsync(_ => var instructions = _state!.IlInstructions!.ToList(); var callIndex = instructions.FindIndex(i => i.OpCode == "call" && i.MetadataToken is not null && i.Operand.Contains("WriteLine")); - Assert.True(callIndex >= 0); + Assert.IsGreaterThanOrEqualTo(0, callIndex); await SetIlCursorOnInstructionAsync(auto, instructions[callIndex]); // Real gd via Enter — cross-assembly into System.Console.dll. @@ -1144,9 +1169,9 @@ await auto.WaitUntilAsync(_ => var expectedCrumb = $" {origTree.Name} > {ns.Name} > {type.Name} "; await auto.WaitUntilTextAsync(expectedCrumb); - Assert.Equal(TabId.SizeMap, _state.CurrentTab); - Assert.Same(type, _state.TreemapCurrentLevel); - Assert.Same(origTree, _state.CachedSizeTree); + Assert.AreEqual(TabId.SizeMap, _state.CurrentTab); + Assert.AreSame(type, _state.TreemapCurrentLevel); + Assert.AreSame(origTree, _state.CachedSizeTree); cts.Cancel(); await runTask; @@ -1159,13 +1184,14 @@ await auto.WaitUntilAsync(_ => /// to the original Size Map drill — chained cross-view jumps must each store /// their own back frame instead of overwriting one another. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void EscBack_FromSizeMapIlHexChain_TwoEscsReturnToSizeMap() { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CachedSizeTree = SizeAnalyzer.BuildSizeTree(state.Analyzer); var origTree = state.CachedSizeTree; @@ -1181,62 +1207,63 @@ public void EscBack_FromSizeMapIlHexChain_TwoEscsReturnToSizeMap() state.CurrentTab = TabId.SizeMap; state.NavigateToIlMethod(method); - Assert.Equal((TabId.SizeMap, 0), state.CrossViewBackTarget); + Assert.AreEqual((TabId.SizeMap, 0), state.CrossViewBackTarget); state.NavigateToHexOffset(method.Rva); - Assert.Equal(TabId.HexDump, state.CurrentTab); - Assert.Equal((TabId.IlInspector, 0), state.CrossViewBackTarget); + Assert.AreEqual(TabId.HexDump, state.CurrentTab); + Assert.AreEqual((TabId.IlInspector, 0), state.CrossViewBackTarget); // First Esc — Hex → IL Inspector. Pre-fix the back target is null after // this because the Hex push clobbered the SizeMap frame. state.NavigateBack(); - Assert.Equal(TabId.IlInspector, state.CurrentTab); - Assert.Equal((TabId.SizeMap, 0), state.CrossViewBackTarget); + Assert.AreEqual(TabId.IlInspector, state.CurrentTab); + Assert.AreEqual((TabId.SizeMap, 0), state.CrossViewBackTarget); // Second Esc — IL → Size Map. state.NavigateBack(); - Assert.Equal(TabId.SizeMap, state.CurrentTab); - Assert.Null(state.CrossViewBackTarget); + Assert.AreEqual(TabId.SizeMap, state.CurrentTab); + Assert.IsNull(state.CrossViewBackTarget); // Breadcrumb survives end-to-end (NavigateToHexOffset never calls ResetViewState). - Assert.Same(origTree, state.CachedSizeTree); - Assert.Same(type, state.TreemapCurrentLevel); - Assert.Equal(2, state.TreemapBreadcrumb.Count); + Assert.AreSame(origTree, state.CachedSizeTree); + Assert.AreSame(type, state.TreemapCurrentLevel); + Assert.HasCount(2, state.TreemapBreadcrumb); } /// /// Verifies that the PE → IL → Hex chain unwinds to the originating PE /// Metadata sub-tab, proving both Tab and SubTab are stored per frame. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void EscBack_FromPeIlHexChain_RestoresPeWithExactSubTab() { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.PeMetadata; state.PeSubTab = PeSubTabId.MethodDef; var method = state.Analyzer.MethodDefs.First(m => m.Rva > 0); state.NavigateToIlMethod(method); - Assert.Equal((TabId.PeMetadata, PeSubTabId.MethodDef), state.CrossViewBackTarget); + Assert.AreEqual((TabId.PeMetadata, PeSubTabId.MethodDef), state.CrossViewBackTarget); state.NavigateToHexOffset(method.Rva); // The Hex frame captures (CurrentTab, PeSubTab) — PeSubTab is unchanged // since the user was on IL Inspector, but the SubTab value is unused // when NavigateBack returns to a non-PE tab. Only assert the Tab here. - Assert.Equal(TabId.IlInspector, state.CrossViewBackTarget!.Value.Tab); + Assert.AreEqual(TabId.IlInspector, state.CrossViewBackTarget!.Value.Tab); state.NavigateBack(); - Assert.Equal(TabId.IlInspector, state.CurrentTab); - Assert.Equal((TabId.PeMetadata, PeSubTabId.MethodDef), state.CrossViewBackTarget); + Assert.AreEqual(TabId.IlInspector, state.CurrentTab); + Assert.AreEqual((TabId.PeMetadata, PeSubTabId.MethodDef), state.CrossViewBackTarget); state.NavigateBack(); - Assert.Equal(TabId.PeMetadata, state.CurrentTab); - Assert.Equal(PeSubTabId.MethodDef, state.PeSubTab); - Assert.Null(state.CrossViewBackTarget); + Assert.AreEqual(TabId.PeMetadata, state.CurrentTab); + Assert.AreEqual(PeSubTabId.MethodDef, state.PeSubTab); + Assert.IsNull(state.CrossViewBackTarget); } /// @@ -1244,10 +1271,11 @@ public void EscBack_FromPeIlHexChain_RestoresPeWithExactSubTab() /// Hex Dump → real Esc → real Esc must land back on Size Map at the /// originating breadcrumb (proves the chain works through real input). /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task EscBack_FromSizeMapIlHexChain_RealKeysReturnToSizeMap() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var (terminal, app) = CreateDotsiderApp(); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -1269,14 +1297,14 @@ public async Task EscBack_FromSizeMapIlHexChain_RealKeysReturnToSizeMap() _state.CurrentTab = TabId.SizeMap; _state.NavigateToIlMethod(callExternal); - Assert.Equal((TabId.SizeMap, 0), _state.CrossViewBackTarget); + Assert.AreEqual((TabId.SizeMap, 0), _state.CrossViewBackTarget); await auto.WaitUntilTextAsync("// Method: RichLibrary.IlNavigationFixture::CallExternal"); // Real x key → NavigateToHexOffset (DllInspectorBindings.cs:221). await auto.KeyAsync(Hex1bKey.X, cts.Token); await auto.WaitUntilAsync(_ => _state!.CurrentTab == TabId.HexDump); - Assert.Equal((TabId.IlInspector, 0), _state.CrossViewBackTarget); + Assert.AreEqual((TabId.IlInspector, 0), _state.CrossViewBackTarget); // First REAL Esc — Hex → IL Inspector. await auto.EscapeAsync(cts.Token); @@ -1290,9 +1318,9 @@ public async Task EscBack_FromSizeMapIlHexChain_RealKeysReturnToSizeMap() var expectedCrumb = $" {origTree.Name} > {ns.Name} > {type.Name} "; await auto.WaitUntilTextAsync(expectedCrumb); - Assert.Equal(TabId.SizeMap, _state.CurrentTab); - Assert.Same(type, _state.TreemapCurrentLevel); - Assert.Null(_state.CrossViewBackTarget); + Assert.AreEqual(TabId.SizeMap, _state.CurrentTab); + Assert.AreSame(type, _state.TreemapCurrentLevel); + Assert.IsNull(_state.CrossViewBackTarget); cts.Cancel(); await runTask; @@ -1302,25 +1330,26 @@ public async Task EscBack_FromSizeMapIlHexChain_RealKeysReturnToSizeMap() /// ResetViewState (triggered by PushAssembly's dependency drill) clears the /// entire cross-view back stack, not just the top frame. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ResetViewState_ClearsEntireBackStack() { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); var method = state.Analyzer.MethodDefs.First(m => m.Rva > 0); state.CurrentTab = TabId.SizeMap; state.NavigateToIlMethod(method); state.NavigateToHexOffset(method.Rva); - Assert.Equal(2, state.CrossViewBackStack.Count); + Assert.HasCount(2, state.CrossViewBackStack); // PushAssembly → ResetViewState clears everything - state.PushAssembly(samples.HelloWorldDll); + state.PushAssembly(Samples.HelloWorldDll); - Assert.Empty(state.CrossViewBackStack); - Assert.Null(state.CrossViewBackTarget); + Assert.IsEmpty(state.CrossViewBackStack); + Assert.IsNull(state.CrossViewBackTarget); } /// @@ -1328,13 +1357,14 @@ public void ResetViewState_ClearsEntireBackStack() /// not just the top tuple. Seeds a 2-deep stack before the gd push so a /// single-tuple snapshot implementation would fail the count assertion. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void CrossAssemblyGd_SnapshotsAndRestoresMultiFrameStack() { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); // Seed a multi-frame stack — bottom: PE/TypeDef, top: SizeMap. state.CrossViewBackStack.Push((TabId.PeMetadata, PeSubTabId.TypeDef)); @@ -1345,7 +1375,7 @@ public void CrossAssemblyGd_SnapshotsAndRestoresMultiFrameStack() m.Name == "CallExternal" && m.DeclaringType.Contains("IlNavigationFixture")); state.IlSelectedMethod = method; var dis = state.IlDisassembler!.DisassembleWithText(method); - Assert.NotNull(dis); + Assert.IsNotNull(dis); state.IlEditorState = new EditorState( new Hex1b.Documents.Hex1bDocument(dis.Value.Text)) { IsReadOnly = true }; state.IlEditorMethod = method; @@ -1353,49 +1383,51 @@ public void CrossAssemblyGd_SnapshotsAndRestoresMultiFrameStack() var callInst = dis.Value.Instructions.First(i => i.OpCode == "call" && i.MetadataToken is not null && i.Operand.Contains("WriteLine")); - Assert.True(state.NavigateToIlDefinition(callInst.MetadataToken!.Value)); + Assert.IsTrue(state.NavigateToIlDefinition(callInst.MetadataToken!.Value)); // Cross-assembly push cleared the stack mid-flight. - Assert.Empty(state.CrossViewBackStack); + Assert.IsEmpty(state.CrossViewBackStack); state.RestoreFromIlBackEntry(state.IlBackStack.Pop()); // Whole stack is restored in correct top-first order. var arr = state.CrossViewBackStack.ToArray(); - Assert.Equal(2, arr.Length); - Assert.Equal((TabId.SizeMap, 0), arr[0]); - Assert.Equal((TabId.PeMetadata, PeSubTabId.TypeDef), arr[1]); + Assert.HasCount(2, arr); + Assert.AreEqual((TabId.SizeMap, 0), arr[0]); + Assert.AreEqual((TabId.PeMetadata, PeSubTabId.TypeDef), arr[1]); } /// /// NavigateToHexOffset bails out early on an invalid RVA without mutating /// the back stack — the early return precedes the push. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigateToHexOffset_InvalidRva_DoesNotMutateBackStack() { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CrossViewBackStack.Push((TabId.SizeMap, 0)); - Assert.Single(state.CrossViewBackStack); + Assert.ContainsSingle(state.CrossViewBackStack); // RvaToFileOffset returns -1 for an out-of-range RVA — early return fires. state.NavigateToHexOffset(int.MaxValue); - Assert.Single(state.CrossViewBackStack); - Assert.Equal((TabId.SizeMap, 0), state.CrossViewBackTarget); + Assert.ContainsSingle(state.CrossViewBackStack); + Assert.AreEqual((TabId.SizeMap, 0), state.CrossViewBackTarget); } /// /// Verifies external method filters by declaring type. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ExternalMethod_FiltersByDeclaringType() { - using var analyzer = new AssemblyAnalyzer(samples.RichLibraryDll); + using var analyzer = new AssemblyAnalyzer(Samples.RichLibraryDll); var dis = new IlDisassembler(analyzer); // Find a method that calls an external method with a common name @@ -1407,7 +1439,7 @@ public void ExternalMethod_FiltersByDeclaringType() i.OpCode == "call" && i.MetadataToken is not null && i.Operand.Contains("WriteLine")); var target = IlNavigationResolver.Resolve(analyzer, callInst.MetadataToken!.Value); - var extMethod = Assert.IsType(target); + var extMethod = Assert.IsExactInstanceOfType(target); // The resolver must capture the declaring type, not just the method name Assert.Contains("Console", extMethod.DeclaringType); @@ -1416,15 +1448,16 @@ public void ExternalMethod_FiltersByDeclaringType() /// /// Verifies net fx external method navigates to mscorlib method. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NetFx_ExternalMethod_NavigatesToMscorlibMethod() { - if (samples.NetFxConsoleExe is null) return; // Windows-only sample + if (Samples.NetFxConsoleExe is null) return; // Windows-only sample var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.NetFxConsoleExe); + using var state = new DotsiderState(app, Samples.NetFxConsoleExe); state.CurrentTab = TabId.IlInspector; // NetFxConsole.Program::Main calls Console.WriteLine which references mscorlib in net48 @@ -1432,7 +1465,7 @@ public void NetFx_ExternalMethod_NavigatesToMscorlibMethod() m.Name == "Main" && m.DeclaringType.Contains("Program")); state.IlSelectedMethod = method; var result = state.IlDisassembler!.DisassembleWithText(method); - Assert.NotNull(result); + Assert.IsNotNull(result); state.IlEditorState = new EditorState( new Hex1b.Documents.Hex1bDocument(result.Value.Text)) { IsReadOnly = true }; state.IlEditorMethod = method; @@ -1444,42 +1477,44 @@ public void NetFx_ExternalMethod_NavigatesToMscorlibMethod() // Resolver must identify this as an external method in mscorlib var target = IlNavigationResolver.Resolve(state.Analyzer, callInst.MetadataToken!.Value); - var extMethod = Assert.IsType(target); - Assert.Equal("mscorlib", extMethod.AssemblyName); + var extMethod = Assert.IsExactInstanceOfType(target); + Assert.AreEqual("mscorlib", extMethod.AssemblyName); // Navigation to mscorlib methods must succeed — the resolver should find // System.Console.dll via namespace probing, not land on Internal.Console in CoreLib var navigated = state.NavigateToIlDefinition(callInst.MetadataToken!.Value); - Assert.True(navigated, $"Navigation failed with notice: {state.TransientNotice}"); - Assert.Null(state.TransientNotice); - Assert.NotNull(state.IlSelectedMethod); - Assert.Equal("System.Console", state.IlSelectedMethod.DeclaringType); + Assert.IsTrue(navigated, $"Navigation failed with notice: {state.TransientNotice}"); + Assert.IsNull(state.TransientNotice); + Assert.IsNotNull(state.IlSelectedMethod); + Assert.AreEqual("System.Console", state.IlSelectedMethod.DeclaringType); } /// /// Verifies mscorlib resolver type forwarders find correct assembly. /// - [Theory(Timeout = 30_000)] - [InlineData("System.Console", "System.Console")] - [InlineData("System.Object", "System.Private.CoreLib")] - [InlineData("System.Collections.Queue", "System.Collections.NonGeneric")] - [InlineData("Microsoft.Win32.RegistryKey", "Microsoft.Win32.Registry")] - [InlineData("System.Environment/SpecialFolder", "System.Private.CoreLib")] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow("System.Console", "System.Console")] + [DataRow("System.Object", "System.Private.CoreLib")] + [DataRow("System.Collections.Queue", "System.Collections.NonGeneric")] + [DataRow("Microsoft.Win32.RegistryKey", "Microsoft.Win32.Registry")] + [DataRow("System.Environment/SpecialFolder", "System.Private.CoreLib")] public void MscorlibResolver_TypeForwarders_FindCorrectAssembly( string declaringType, string expectedAssembly) { ImplementationAssemblyResolver.ClearCache(); var resolved = ImplementationAssemblyResolver.Resolve( - samples.RichLibraryDll, "mscorlib", declaringType); - Assert.NotNull(resolved); - var fromFile = Assert.IsType(resolved); + Samples.RichLibraryDll, "mscorlib", declaringType); + Assert.IsNotNull(resolved); + var fromFile = Assert.IsExactInstanceOfType(resolved); Assert.Contains(expectedAssembly, Path.GetFileNameWithoutExtension(fromFile.Path)); } /// /// Verifies mscorlib resolver nested type forwarder follows parent chain. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void MscorlibResolver_NestedTypeForwarder_FollowsParentChain() { // System.Environment forwards to System.Private.CoreLib on modern .NET. @@ -1487,32 +1522,33 @@ public void MscorlibResolver_NestedTypeForwarder_FollowsParentChain() // proving the Implementation chain is followed rather than falling back. ImplementationAssemblyResolver.ClearCache(); var parent = ImplementationAssemblyResolver.Resolve( - samples.RichLibraryDll, "mscorlib", "System.Environment"); + Samples.RichLibraryDll, "mscorlib", "System.Environment"); ImplementationAssemblyResolver.ClearCache(); var nested = ImplementationAssemblyResolver.Resolve( - samples.RichLibraryDll, "mscorlib", "System.Environment/SpecialFolder"); - Assert.NotNull(parent); - Assert.NotNull(nested); - Assert.Equal(parent, nested); + Samples.RichLibraryDll, "mscorlib", "System.Environment/SpecialFolder"); + Assert.IsNotNull(parent); + Assert.IsNotNull(nested); + Assert.AreEqual(parent, nested); } /// /// Verifies external method navigates to correct declaring type. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ExternalMethod_NavigatesToCorrectDeclaringType() { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.IlInspector; var method = state.Analyzer.MethodDefs.First(m => m.Name == "CallExternal" && m.DeclaringType.Contains("IlNavigationFixture")); state.IlSelectedMethod = method; var result = state.IlDisassembler!.DisassembleWithText(method); - Assert.NotNull(result); + Assert.IsNotNull(result); state.IlEditorState = new EditorState( new Hex1b.Documents.Hex1bDocument(result.Value.Text)) { IsReadOnly = true }; state.IlEditorMethod = method; @@ -1525,11 +1561,11 @@ public void ExternalMethod_NavigatesToCorrectDeclaringType() if (navigated) { // Must have navigated to the correct assembly - Assert.True(state.NavigationStack.Count > 0, "Should have pushed assembly"); + Assert.IsGreaterThan(0, state.NavigationStack.Count, "Should have pushed assembly"); // The selected method's declaring type must contain "Console" // (not some other type that also has a WriteLine-like method) - Assert.NotNull(state.IlSelectedMethod); + Assert.IsNotNull(state.IlSelectedMethod); Assert.Contains("Console", state.IlSelectedMethod.DeclaringType); } } @@ -1537,13 +1573,14 @@ public void ExternalMethod_NavigatesToCorrectDeclaringType() /// /// Verifies external field navigation sets il selected field. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ExternalField_NavigationSetsIlSelectedField() { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.IlInspector; // GetStringEmpty calls string.Empty which is ldsfld → ExternalField @@ -1552,7 +1589,7 @@ public void ExternalField_NavigationSetsIlSelectedField() state.IlSelectedMethod = method; var result = state.IlDisassembler!.DisassembleWithText(method); - Assert.NotNull(result); + Assert.IsNotNull(result); state.IlEditorState = new EditorState( new Hex1b.Documents.Hex1bDocument(result.Value.Text)) { IsReadOnly = true }; state.IlEditorMethod = method; @@ -1563,23 +1600,24 @@ public void ExternalField_NavigationSetsIlSelectedField() i.OpCode == "ldsfld" && i.MetadataToken is not null && i.Operand.Contains("Empty")); var target = IlNavigationResolver.Resolve(state.Analyzer, fieldInst.MetadataToken!.Value); - Assert.IsType(target); + Assert.IsExactInstanceOfType(target); var navigated = state.NavigateToIlDefinition(fieldInst.MetadataToken!.Value); - Assert.True(navigated, "External field navigation must succeed"); + Assert.IsTrue(navigated, "External field navigation must succeed"); // External field navigation must set IlSelectedField for the right pane - Assert.NotNull(state.IlSelectedField); + Assert.IsNotNull(state.IlSelectedField); Assert.Contains("Empty", state.IlSelectedField.Name); // Must have pushed assembly - Assert.True(state.NavigationStack.Count > 0); + Assert.IsGreaterThan(0, state.NavigationStack.Count); // Esc back must restore var entry = state.IlBackStack.Pop(); state.RestoreFromIlBackEntry(entry); - Assert.Equal("GetStringEmpty", state.IlSelectedMethod?.Name); - Assert.Null(state.IlSelectedField); + Assert.IsNotNull(state.IlSelectedMethod); + Assert.AreEqual("GetStringEmpty", state.IlSelectedMethod.Name); + Assert.IsNull(state.IlSelectedField); } /// @@ -1587,10 +1625,11 @@ public void ExternalField_NavigationSetsIlSelectedField() /// UserService.FindByRole) and expects the underlying ExternalMethod target — /// not a GenericInstantiation fallback. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Resolve_MethodSpec_ReturnsUnderlyingExternalMethod() { - using var analyzer = new AssemblyAnalyzer(samples.RichLibraryDll); + using var analyzer = new AssemblyAnalyzer(Samples.RichLibraryDll); var dis = new IlDisassembler(analyzer); var method = analyzer.MethodDefs.First(m => m.Name == "FindByRole" && m.DeclaringType.Contains("UserService")); @@ -1601,9 +1640,9 @@ public void Resolve_MethodSpec_ReturnsUnderlyingExternalMethod() var target = IlNavigationResolver.Resolve(analyzer, callInst.MetadataToken!.Value); - var ext = Assert.IsType(target); - Assert.Equal("Where", ext.MemberName); - Assert.Equal("System.Linq.Enumerable", ext.DeclaringType); + var ext = Assert.IsExactInstanceOfType(target); + Assert.AreEqual("Where", ext.MemberName); + Assert.AreEqual("System.Linq.Enumerable", ext.DeclaringType); // Signature should encode the method generic parameter as !!0, proving // we resolved the open-generic definition rather than coincidental match. Assert.Contains("!!0", ext.Signature); @@ -1612,20 +1651,21 @@ public void Resolve_MethodSpec_ReturnsUnderlyingExternalMethod() /// /// Navigates from a MethodSpec call site to the open-generic definition in System.Linq. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigateToIlDefinition_MethodSpec_LandsOnOpenGenericDefinition() { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.IlInspector; var method = state.Analyzer.MethodDefs.First(m => m.Name == "FindByRole" && m.DeclaringType.Contains("UserService")); state.IlSelectedMethod = method; var result = state.IlDisassembler!.DisassembleWithText(method); - Assert.NotNull(result); + Assert.IsNotNull(result); state.IlEditorState = new EditorState( new Hex1b.Documents.Hex1bDocument(result.Value.Text)) { IsReadOnly = true }; state.IlEditorMethod = method; @@ -1638,13 +1678,13 @@ public void NavigateToIlDefinition_MethodSpec_LandsOnOpenGenericDefinition() var navigated = state.NavigateToIlDefinition(callInst.MetadataToken!.Value); - Assert.True(navigated, "MethodSpec navigation must succeed for Enumerable.Where"); - Assert.True(state.NavigationStack.Count > 0, "Should have pushed System.Linq"); - Assert.NotNull(state.IlSelectedMethod); - Assert.Equal("Where", state.IlSelectedMethod.Name); - Assert.Equal("System.Linq.Enumerable", state.IlSelectedMethod.DeclaringType); + Assert.IsTrue(navigated, "MethodSpec navigation must succeed for Enumerable.Where"); + Assert.IsGreaterThan(0, state.NavigationStack.Count, "Should have pushed System.Linq"); + Assert.IsNotNull(state.IlSelectedMethod); + Assert.AreEqual("Where", state.IlSelectedMethod.Name); + Assert.AreEqual("System.Linq.Enumerable", state.IlSelectedMethod.DeclaringType); Assert.Contains("!!0", state.IlSelectedMethod.Signature); - Assert.Equal(TabId.IlInspector, state.CurrentTab); + Assert.AreEqual(TabId.IlInspector, state.CurrentTab); } /// @@ -1653,10 +1693,11 @@ public void NavigateToIlDefinition_MethodSpec_LandsOnOpenGenericDefinition() /// the parent is a TypeSpec, not a TypeRef. The resolver must still identify it as /// an ExternalMethod, not return Unsupported. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Resolve_MemberRefWithTypeSpecParent_ReturnsExternalMethod() { - using var analyzer = new AssemblyAnalyzer(samples.RichLibraryDll); + using var analyzer = new AssemblyAnalyzer(Samples.RichLibraryDll); var dis = new IlDisassembler(analyzer); var method = analyzer.MethodDefs.First(m => @@ -1667,8 +1708,8 @@ public void Resolve_MemberRefWithTypeSpecParent_ReturnsExternalMethod() var target = IlNavigationResolver.Resolve(analyzer, callInst.MetadataToken!.Value); - var ext = Assert.IsType(target); - Assert.Equal("set_Item", ext.MemberName); + var ext = Assert.IsExactInstanceOfType(target); + Assert.AreEqual("set_Item", ext.MemberName); Assert.Contains("ConcurrentDictionary", ext.DeclaringType); } @@ -1676,27 +1717,28 @@ public void Resolve_MemberRefWithTypeSpecParent_ReturnsExternalMethod() /// A malformed MethodSpec token must surface as GenericInstantiation with a /// non-empty Reason, and the DotsiderState arm must report it via TransientNotice. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Resolve_InvalidMethodSpecToken_ReturnsGenericInstantiationWithReason() { - using var analyzer = new AssemblyAnalyzer(samples.RichLibraryDll); + using var analyzer = new AssemblyAnalyzer(Samples.RichLibraryDll); // HandleKind.MethodSpecification = 0x2B. Row 0xFFFFFF is well past any real row, // so reader.GetMethodSpecification will throw BadImageFormatException. var invalidToken = unchecked((int)0x2BFFFFFF); var target = IlNavigationResolver.Resolve(analyzer, invalidToken); - var gi = Assert.IsType(target); - Assert.Equal(invalidToken, gi.Token); - Assert.False(string.IsNullOrEmpty(gi.Reason)); + var gi = Assert.IsExactInstanceOfType(target); + Assert.AreEqual(invalidToken, gi.Token); + Assert.IsFalse(string.IsNullOrEmpty(gi.Reason)); var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); var navigated = state.NavigateToIlDefinition(invalidToken); - Assert.False(navigated); - Assert.NotNull(state.TransientNotice); + Assert.IsFalse(navigated); + Assert.IsNotNull(state.TransientNotice); Assert.Contains("generic instantiation", state.TransientNotice, StringComparison.OrdinalIgnoreCase); } @@ -1707,37 +1749,40 @@ public void Resolve_InvalidMethodSpecToken_ReturnsGenericInstantiationWithReason /// List`1 to System.Private.CoreLib. The resolver must land there, not inside /// the facade. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ExternalMethod_ForwardedFromPartialFacade_Ctor_LandsInCoreLib() => AssertForwardedListMemberLandsInCoreLib("newobj", ".ctor"); /// /// Same partial-facade chase, but for the callvirt on List<byte[]>::Add. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ExternalMethod_ForwardedFromPartialFacade_Add_LandsInCoreLib() => AssertForwardedListMemberLandsInCoreLib("callvirt", "Add"); /// /// Same partial-facade chase, but for the callvirt on List<byte[]>::Clear. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ExternalMethod_ForwardedFromPartialFacade_Clear_LandsInCoreLib() => AssertForwardedListMemberLandsInCoreLib("callvirt", "Clear"); - private void AssertForwardedListMemberLandsInCoreLib(string opCode, string memberName) + private static void AssertForwardedListMemberLandsInCoreLib(string opCode, string memberName) { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.HelloWorldDll); + using var state = new DotsiderState(app, Samples.HelloWorldDll); state.CurrentTab = TabId.IlInspector; var moveNext = state.Analyzer.MethodDefs.First(m => m.Name == "MoveNext" && m.DeclaringType.Contains("
$")); state.IlSelectedMethod = moveNext; var result = state.IlDisassembler!.DisassembleWithText(moveNext); - Assert.NotNull(result); + Assert.IsNotNull(result); state.IlEditorState = new EditorState( new Hex1b.Documents.Hex1bDocument(result.Value.Text)) { IsReadOnly = true }; state.IlEditorMethod = moveNext; @@ -1753,13 +1798,13 @@ private void AssertForwardedListMemberLandsInCoreLib(string opCode, string membe var navigated = state.NavigateToIlDefinition(inst.MetadataToken!.Value); - Assert.True(navigated, $"Navigation must succeed for List`1::{memberName}"); - Assert.Null(state.TransientNotice); - Assert.True(state.NavigationStack.Count > 0, "Should have pushed assembly"); - Assert.NotNull(state.IlSelectedMethod); - Assert.Equal(memberName, state.IlSelectedMethod.Name); - Assert.Equal("System.Collections.Generic.List`1", state.IlSelectedMethod.DeclaringType); - Assert.Equal("System.Private.CoreLib.dll", + Assert.IsTrue(navigated, $"Navigation must succeed for List`1::{memberName}"); + Assert.IsNull(state.TransientNotice); + Assert.IsGreaterThan(0, state.NavigationStack.Count, "Should have pushed assembly"); + Assert.IsNotNull(state.IlSelectedMethod); + Assert.AreEqual(memberName, state.IlSelectedMethod.Name); + Assert.AreEqual("System.Collections.Generic.List`1", state.IlSelectedMethod.DeclaringType); + Assert.AreEqual("System.Private.CoreLib.dll", Path.GetFileName(state.Analyzer.FilePath)); } @@ -1768,20 +1813,21 @@ private void AssertForwardedListMemberLandsInCoreLib(string opCode, string membe /// of System.Collections.dll's real TypeDefs, not a forwarder — the resolver /// must stay in the facade rather than over-chasing into CoreLib. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ExternalMethod_LocallyOwnedInPartialFacade_StaysInFacade() { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.IlInspector; var method = state.Analyzer.MethodDefs.First(m => m.Name == "CreateLinkedList" && m.DeclaringType.Contains("IlNavigationFixture")); state.IlSelectedMethod = method; var result = state.IlDisassembler!.DisassembleWithText(method); - Assert.NotNull(result); + Assert.IsNotNull(result); state.IlEditorState = new EditorState( new Hex1b.Documents.Hex1bDocument(result.Value.Text)) { IsReadOnly = true }; state.IlEditorMethod = method; @@ -1797,11 +1843,11 @@ public void ExternalMethod_LocallyOwnedInPartialFacade_StaysInFacade() var navigated = state.NavigateToIlDefinition(inst.MetadataToken!.Value); - Assert.True(navigated, "Navigation must succeed for LinkedList`1::.ctor"); - Assert.Null(state.TransientNotice); - Assert.NotNull(state.IlSelectedMethod); - Assert.Equal("System.Collections.Generic.LinkedList`1", state.IlSelectedMethod.DeclaringType); - Assert.Equal("System.Collections.dll", + Assert.IsTrue(navigated, "Navigation must succeed for LinkedList`1::.ctor"); + Assert.IsNull(state.TransientNotice); + Assert.IsNotNull(state.IlSelectedMethod); + Assert.AreEqual("System.Collections.Generic.LinkedList`1", state.IlSelectedMethod.DeclaringType); + Assert.AreEqual("System.Collections.dll", Path.GetFileName(state.Analyzer.FilePath)); } @@ -1811,10 +1857,11 @@ public void ExternalMethod_LocallyOwnedInPartialFacade_StaysInFacade() /// context to know which owner "!N" refers to; with that context it routes /// to the enclosing type definition (where the GenericParam row lives). /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Resolve_TypeSpecGenericTypeParam_WithContext_ReturnsEnclosingType() { - using var analyzer = new AssemblyAnalyzer(samples.RichLibraryDll); + using var analyzer = new AssemblyAnalyzer(Samples.RichLibraryDll); var dis = new IlDisassembler(analyzer); var method = analyzer.MethodDefs.First(m => m.Name == "DefaultValue" && m.DeclaringType.Contains("GenericParamFixture")); @@ -1823,8 +1870,8 @@ public void Resolve_TypeSpecGenericTypeParam_WithContext_ReturnsEnclosingType() var target = IlNavigationResolver.Resolve(analyzer, initobj.MetadataToken!.Value, method); - var local = Assert.IsType(target); - Assert.Equal("RichLibrary.GenericParamFixture`2", local.Type.FullName); + var local = Assert.IsExactInstanceOfType(target); + Assert.AreEqual("RichLibrary.GenericParamFixture`2", local.Type.FullName); } /// @@ -1833,10 +1880,11 @@ public void Resolve_TypeSpecGenericTypeParam_WithContext_ReturnsEnclosingType() /// signature, so the resolver reports it via Unsupported (a transient notice) /// rather than a self-navigation that the UI would silently swallow. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Resolve_TypeSpecGenericMethodParam_WithContext_ReportsDefinedBySignature() { - using var analyzer = new AssemblyAnalyzer(samples.RichLibraryDll); + using var analyzer = new AssemblyAnalyzer(Samples.RichLibraryDll); var dis = new IlDisassembler(analyzer); var method = analyzer.MethodDefs.First(m => m.Name == "DefaultMethodParam" && m.DeclaringType.Contains("GenericParamFixture")); @@ -1845,7 +1893,7 @@ public void Resolve_TypeSpecGenericMethodParam_WithContext_ReportsDefinedBySigna var target = IlNavigationResolver.Resolve(analyzer, initobj.MetadataToken!.Value, method); - var unsupported = Assert.IsType(target); + var unsupported = Assert.IsExactInstanceOfType(target); Assert.Contains("!!0", unsupported.Reason); Assert.Contains("DefaultMethodParam", unsupported.Reason); } @@ -1856,20 +1904,21 @@ public void Resolve_TypeSpecGenericMethodParam_WithContext_ReportsDefinedBySigna /// UI gets a clear "there's nothing to navigate to" signal instead of a /// silent no-op. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigateToIlDefinition_TypeSpecGenericMethodParam_RaisesNotice() { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.IlInspector; var method = state.Analyzer.MethodDefs.First(m => m.Name == "DefaultMethodParam" && m.DeclaringType.Contains("GenericParamFixture")); state.IlSelectedMethod = method; var result = state.IlDisassembler!.DisassembleWithText(method); - Assert.NotNull(result); + Assert.IsNotNull(result); state.IlEditorState = new EditorState( new Hex1b.Documents.Hex1bDocument(result.Value.Text)) { IsReadOnly = true }; state.IlEditorMethod = method; @@ -1880,10 +1929,10 @@ public void NavigateToIlDefinition_TypeSpecGenericMethodParam_RaisesNotice() var navigated = state.NavigateToIlDefinition(initobj.MetadataToken!.Value); - Assert.False(navigated); - Assert.NotNull(state.TransientNotice); + Assert.IsFalse(navigated); + Assert.IsNotNull(state.TransientNotice); Assert.Contains("!!0", state.TransientNotice); - Assert.Same(method, state.IlSelectedMethod); + Assert.AreSame(method, state.IlSelectedMethod); } /// @@ -1891,10 +1940,11 @@ public void NavigateToIlDefinition_TypeSpecGenericMethodParam_RaisesNotice() /// resolvable. The resolver should surface a message that explains what's /// missing rather than the opaque "Cannot resolve TypeSpec: !1". /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Resolve_TypeSpecGenericTypeParam_WithoutContext_ReportsMissingContext() { - using var analyzer = new AssemblyAnalyzer(samples.RichLibraryDll); + using var analyzer = new AssemblyAnalyzer(Samples.RichLibraryDll); var dis = new IlDisassembler(analyzer); var method = analyzer.MethodDefs.First(m => m.Name == "DefaultValue" && m.DeclaringType.Contains("GenericParamFixture")); @@ -1903,7 +1953,7 @@ public void Resolve_TypeSpecGenericTypeParam_WithoutContext_ReportsMissingContex var target = IlNavigationResolver.Resolve(analyzer, initobj.MetadataToken!.Value); - var unsupported = Assert.IsType(target); + var unsupported = Assert.IsExactInstanceOfType(target); Assert.Contains("!1", unsupported.Reason); Assert.Contains("context", unsupported.Reason, StringComparison.OrdinalIgnoreCase); } @@ -1913,20 +1963,21 @@ public void Resolve_TypeSpecGenericTypeParam_WithoutContext_ReportsMissingContex /// lands on the enclosing type, clears the selected method, and raises no /// transient notice. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigateToIlDefinition_TypeSpecGenericTypeParam_LandsOnEnclosingType() { var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.CurrentTab = TabId.IlInspector; var method = state.Analyzer.MethodDefs.First(m => m.Name == "DefaultValue" && m.DeclaringType.Contains("GenericParamFixture")); state.IlSelectedMethod = method; var result = state.IlDisassembler!.DisassembleWithText(method); - Assert.NotNull(result); + Assert.IsNotNull(result); state.IlEditorState = new EditorState( new Hex1b.Documents.Hex1bDocument(result.Value.Text)) { IsReadOnly = true }; state.IlEditorMethod = method; @@ -1937,16 +1988,16 @@ public void NavigateToIlDefinition_TypeSpecGenericTypeParam_LandsOnEnclosingType var navigated = state.NavigateToIlDefinition(initobj.MetadataToken!.Value); - Assert.True(navigated, "Navigation to generic type parameter's owner must succeed"); - Assert.Null(state.TransientNotice); - Assert.Equal( + Assert.IsTrue(navigated, "Navigation to generic type parameter's owner must succeed"); + Assert.IsNull(state.TransientNotice); + Assert.AreEqual( "type:RichLibrary.GenericParamFixture`2", state.IlFocusedTreeKey as string); // Method/editor selection must be cleared so the right pane stops showing // DefaultValue's IL. Without that the navigation only half-applies. - Assert.Null(state.IlSelectedMethod); - Assert.Null(state.IlEditorMethod); - Assert.Null(state.IlEditorState); - Assert.Null(state.IlEditorAnalyzer); + Assert.IsNull(state.IlSelectedMethod); + Assert.IsNull(state.IlEditorMethod); + Assert.IsNull(state.IlEditorState); + Assert.IsNull(state.IlEditorAnalyzer); } } diff --git a/tests/Dotsider.Tests/IlInspectorScrollbarTests.cs b/tests/Dotsider.Tests/IlInspectorScrollbarTests.cs index b1307cce..3551f67b 100644 --- a/tests/Dotsider.Tests/IlInspectorScrollbarTests.cs +++ b/tests/Dotsider.Tests/IlInspectorScrollbarTests.cs @@ -11,14 +11,17 @@ namespace Dotsider.Tests; /// Behavior tests for the IL Inspector tree's -hosted /// scrollbar and non-wrapping selection. Issue #167. /// -[Collection("SampleAssemblies")] -public class IlInspectorScrollbarTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class IlInspectorScrollbarTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _hex1bApp; private DotsiderState? _state; private CancellationTokenSource? _cts; + private Task? _runTask; /// /// Creates a dotsider test app with mouse input enabled so wheel/drag/track tests can @@ -32,7 +35,7 @@ public class IlInspectorScrollbarTests(SampleAssemblyFixture samples) : IDisposa private (Hex1bTerminal terminal, Hex1bApp app, CancellationToken ct) CreateMouseApp( string dllPath, bool enableInputCoalescing = false) { - _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + _cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); _workload = new Hex1bAppWorkloadAdapter(); _terminal = Hex1bTerminal.CreateBuilder() .WithWorkload(_workload) @@ -56,6 +59,20 @@ public class IlInspectorScrollbarTests(SampleAssemblyFixture samples) : IDisposa return (_terminal, _hex1bApp, _cts.Token); } + private Task RunAppAsync(Hex1bApp app, CancellationToken ct) + { + _runTask = app.RunAsync(ct); + return _runTask; + } + + private bool TryWaitForAppExit() + { + if (_runTask is null) return true; + try { return _runTask.Wait(TimeSpan.FromSeconds(5)); } + catch (AggregateException ex) when (ex.InnerExceptions.All(static e => e is OperationCanceledException)) { return true; } + catch (OperationCanceledException) { return true; } + } + /// /// Programmatically expands every namespace and every type in the tree so the /// flattened-row count is large enough to exceed the viewport on the default 120×30 @@ -108,15 +125,14 @@ private async Task SetupIlTabAsync(Hex1bTerminal terminal, Action /// /// Copies the focus ring to an array, treating the transient - /// a concurrent render-thread rebuild can raise - /// mid-enumeration as an empty result. Hex1b rebuilds the ring (a plain list) after every - /// frame, so a read from the test poll thread must tolerate that race and retry rather than - /// fault the test. + /// exceptions a concurrent render-thread rebuild can raise mid-copy as an empty result. + /// Hex1b rebuilds the ring (a plain list) after every frame, so a read from the test poll + /// thread must tolerate that race and retry rather than fault the test. /// private static Hex1bNode[] SnapshotFocusables(Hex1bApp app) { try { return [.. app.Focusables]; } - catch (InvalidOperationException) { return []; } + catch (Exception ex) when (ex is InvalidOperationException or ArgumentException) { return []; } } private static ScrollPanelNode? FindPanel(Hex1bApp app) @@ -213,17 +229,23 @@ await auto.WaitUntilAsync(_ => return sp is { ViewportSize: > 0 } && sp.ContentSize == Math.Min(sp.ViewportSize, rows.Count); }, description: "panel ContentSize agrees with the visible window"); + + app.RequestFocus(node => node is ScrollPanelNode); + app.Invalidate(); + await auto.WaitUntilAsync(_ => SnapshotFocusables(app).FirstOrDefault(n => n.IsFocused) is ScrollPanelNode, + description: "ScrollPanelNode focused after direct selection"); } /// /// First arrival paints the scrollbar without requiring an extra keystroke. /// Pins the bootstrap-invalidate fix. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_TreeScrollbar_RendersOnFirstArrival_WithoutExtraInput() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); _state!.App.Invalidate(); @@ -244,12 +266,14 @@ await auto.WaitUntilAsync(s => /// When the tree exceeds the viewport, the scrollbar paints a thumb cell in the panel's rightmost column. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_TreeScrollbar_RendersAtRightEdge_WhenContentExceedsViewport() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); _state!.App.Invalidate(); @@ -258,7 +282,7 @@ public async Task Tab3_TreeScrollbar_RendersAtRightEdge_WhenContentExceedsViewpo await auto.WaitUntilAsync(_ => TreeScrollable(sp), description: "tree becomes scrollable"); var thumbY = await WaitForThumbAsync(auto, terminal, sp); - Assert.True(thumbY >= sp.Bounds.Y, "thumb cell rendered inside panel bounds"); + Assert.IsGreaterThanOrEqualTo(sp.Bounds.Y, thumbY, "thumb cell rendered inside panel bounds"); _cts!.Cancel(); await runTask; @@ -266,12 +290,14 @@ public async Task Tab3_TreeScrollbar_RendersAtRightEdge_WhenContentExceedsViewpo /// When all rows fit the viewport, no thumb cell is painted. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_TreeScrollbar_HiddenWhenContentFits() { - var (terminal, app, ct) = CreateMouseApp(samples.HelloWorldDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.HelloWorldDll); + var runTask = RunAppAsync(app, ct); await SwitchToIlAsync(terminal, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -281,7 +307,7 @@ public async Task Tab3_TreeScrollbar_HiddenWhenContentFits() var snapshot = terminal.CreateSnapshot(); var sbCol = sp.Bounds.X + sp.Bounds.Width - 1; var hasThumb = FirstThumbY(snapshot, sbCol, sp.Bounds.Y, sp.Bounds.Y + sp.Bounds.Height) >= 0; - Assert.False(hasThumb, "No thumb expected when content fits"); + Assert.IsFalse(hasThumb, "No thumb expected when content fits"); _cts!.Cancel(); await runTask; @@ -289,12 +315,14 @@ public async Task Tab3_TreeScrollbar_HiddenWhenContentFits() /// DownArrow at the last row is a no-op (clamp, not wrap). - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_DownArrow_AtBottom_DoesNotWrap() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -307,7 +335,7 @@ public async Task Tab3_DownArrow_AtBottom_DoesNotWrap() await auto.KeyAsync(Hex1bKey.DownArrow, ct: ct); await Task.Delay(50, ct); - Assert.Equal(lastKey, _state!.IlFocusedTreeKey as string); + Assert.AreEqual(lastKey, _state!.IlFocusedTreeKey as string); _cts!.Cancel(); await runTask; @@ -315,12 +343,14 @@ public async Task Tab3_DownArrow_AtBottom_DoesNotWrap() /// UpArrow at the first row is a no-op (clamp, not wrap). - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_UpArrow_AtTop_DoesNotWrap() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SwitchToIlAsync(terminal, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -333,7 +363,7 @@ public async Task Tab3_UpArrow_AtTop_DoesNotWrap() await auto.KeyAsync(Hex1bKey.UpArrow, ct: ct); await Task.Delay(50, ct); - Assert.Equal(firstKey, _state!.IlFocusedTreeKey as string); + Assert.AreEqual(firstKey, _state!.IlFocusedTreeKey as string); _cts!.Cancel(); await runTask; @@ -341,12 +371,14 @@ public async Task Tab3_UpArrow_AtTop_DoesNotWrap() /// DownArrow advances selection past the viewport bottom; the panel offset follows. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_DownArrow_MovesSelection_AndScrollsViewportToFollow() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -370,8 +402,7 @@ await auto.WaitUntilAsync(_ => _state!.IlFocusedTreeKey as string != prev, description: $"DownArrow #{i + 1} advances from {prev}"); } - Assert.True(TreeOffset > 0, - $"Offset should advance after walking past viewport. ViewportSize={sp.ViewportSize}, target={target}, Offset={TreeOffset}"); + Assert.IsGreaterThan(0, TreeOffset, $"Offset should advance after walking past viewport. ViewportSize={sp.ViewportSize}, target={target}, Offset={TreeOffset}"); _cts!.Cancel(); await runTask; @@ -379,12 +410,14 @@ await auto.WaitUntilAsync(_ => _state!.IlFocusedTreeKey as string != prev, /// PageDown advances selection by Math.Max(1, ViewportSize - 1). - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_PageDown_AdvancesByPanelPageSize() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -406,12 +439,14 @@ await auto.WaitUntilAsync(_ => _state!.IlFocusedTreeKey as string == expectedKey /// PageUp retreats selection by Math.Max(1, ViewportSize - 1). - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_PageUp_RetreatsByPanelPageSize() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -436,12 +471,14 @@ await auto.WaitUntilAsync(_ => _state!.IlFocusedTreeKey as string == expectedKey /// Home jumps selection to row 0 and resets the panel offset to zero. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_Home_JumpsToFirstRow_AndOffsetReturnsToZero() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -462,12 +499,14 @@ await auto.WaitUntilAsync(_ => _state!.IlFocusedTreeKey as string == rows[0].Key /// End jumps selection to the last row and pushes the panel offset to MaxOffset. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_End_JumpsToLastRow_AndOffsetReachesMax() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -489,11 +528,12 @@ await auto.WaitUntilAsync(_ => TreeOffset == TreeMaxOffset(sp), /// hex1b default) without changing selection. Selection-coupled designs would /// fail this — the test pins the ScrollPanel-as-focusable architecture. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_MouseWheelDown_OverTreeBody_AdvancesOffsetBy3_WithoutChangingSelection() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -515,7 +555,7 @@ public async Task Tab3_MouseWheelDown_OverTreeBody_AdvancesOffsetBy3_WithoutChan await auto.WaitUntilAsync(_ => TreeOffset == initialOffset + 3, description: "Offset advanced by exactly 3"); - Assert.Equal(initialKey, _state!.IlFocusedTreeKey as string); + Assert.AreEqual(initialKey, _state!.IlFocusedTreeKey as string); _cts!.Cancel(); await runTask; @@ -523,12 +563,14 @@ await auto.WaitUntilAsync(_ => TreeOffset == initialOffset + 3, /// Wheel-up over the tree at offset zero is a no-op for both offset and selection. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_MouseWheelUp_AtTop_OffsetStaysAtZero() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -546,8 +588,8 @@ public async Task Tab3_MouseWheelUp_AtTop_OffsetStaysAtZero() .ApplyAsync(terminal, ct); await Task.Delay(50, ct); - Assert.Equal(0, TreeOffset); - Assert.Equal(initialKey, _state!.IlFocusedTreeKey as string); + Assert.AreEqual(0, TreeOffset); + Assert.AreEqual(initialKey, _state!.IlFocusedTreeKey as string); _cts!.Cancel(); await runTask; @@ -555,12 +597,14 @@ public async Task Tab3_MouseWheelUp_AtTop_OffsetStaysAtZero() /// Wheel-down over the tree at MaxOffset clamps; selection is unchanged. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_MouseWheelDown_AtBottom_ClampsAtMaxOffset() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -580,8 +624,8 @@ public async Task Tab3_MouseWheelDown_AtBottom_ClampsAtMaxOffset() .ApplyAsync(terminal, ct); await Task.Delay(50, ct); - Assert.Equal(TreeMaxOffset(sp), TreeOffset); - Assert.Equal(initialKey, _state!.IlFocusedTreeKey as string); + Assert.AreEqual(TreeMaxOffset(sp), TreeOffset); + Assert.AreEqual(initialKey, _state!.IlFocusedTreeKey as string); _cts!.Cancel(); await runTask; @@ -589,12 +633,14 @@ public async Task Tab3_MouseWheelDown_AtBottom_ClampsAtMaxOffset() /// Click on the scrollbar track below the thumb pages the viewport by one page; selection is unchanged. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_ScrollbarTrackClick_PagesViewport_WithoutChangingSelection() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -617,7 +663,7 @@ public async Task Tab3_ScrollbarTrackClick_PagesViewport_WithoutChangingSelectio await auto.WaitUntilAsync(_ => TreeOffset == Math.Min(TreeMaxOffset(sp), initialOffset + expectedStep), description: "track click pages viewport"); - Assert.Equal(initialKey, _state!.IlFocusedTreeKey as string); + Assert.AreEqual(initialKey, _state!.IlFocusedTreeKey as string); _cts!.Cancel(); await runTask; @@ -625,12 +671,14 @@ await auto.WaitUntilAsync(_ => TreeOffset == Math.Min(TreeMaxOffset(sp), initial /// Drag on the thumb advances the panel offset; selection is unchanged. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_ScrollbarThumbDrag_UpdatesOffset_WithoutChangingSelection() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -648,7 +696,7 @@ public async Task Tab3_ScrollbarThumbDrag_UpdatesOffset_WithoutChangingSelection .ApplyAsync(terminal, ct); await auto.WaitUntilAsync(_ => TreeOffset > 0, description: "drag advanced offset"); - Assert.Equal(initialKey, _state!.IlFocusedTreeKey as string); + Assert.AreEqual(initialKey, _state!.IlFocusedTreeKey as string); _cts!.Cancel(); await runTask; @@ -656,12 +704,14 @@ public async Task Tab3_ScrollbarThumbDrag_UpdatesOffset_WithoutChangingSelection /// The thumb cell moves further down the gutter when offset moves from 0 to MaxOffset. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_ScrollbarThumbReflectsScrollOffset() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -688,11 +738,12 @@ await auto.WaitUntilAsync(s => /// ScrollPanelNode kept keyboard focus through the drag (no extra FocusWhere needed /// because the panel is the only focusable for the tree). /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_AfterScrollbarDrag_DownArrow_AdvancesSelection() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll, enableInputCoalescing: true); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll, enableInputCoalescing: true); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -713,7 +764,7 @@ public async Task Tab3_AfterScrollbarDrag_DownArrow_AdvancesSelection() .Build() .ApplyAsync(terminal, ct); - Assert.NotEqual(beforeKey, _state!.IlFocusedTreeKey as string); + Assert.AreNotEqual(beforeKey, _state!.IlFocusedTreeKey as string); _cts!.Cancel(); await runTask; @@ -723,11 +774,12 @@ public async Task Tab3_AfterScrollbarDrag_DownArrow_AdvancesSelection() /// No-op thumb click followed by DownArrow still advances selection — proves the /// panel-as-focusable design has no spare focusable that could absorb the key. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_NoOpScrollbarClick_DownArrow_AdvancesSelection() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -739,8 +791,7 @@ public async Task Tab3_NoOpScrollbarClick_DownArrow_AdvancesSelection() var sbCol = sp.Bounds.X + sp.Bounds.Width - 1; var thumbY = await WaitForThumbAsync(auto, terminal, sp); - Assert.True(sp.Bounds.X <= sbCol && sbCol < sp.Bounds.X + sp.Bounds.Width, - "thumb column must be inside panel bounds"); + Assert.IsTrue(sp.Bounds.X <= sbCol && sbCol < sp.Bounds.X + sp.Bounds.Width, "thumb column must be inside panel bounds"); var beforeKey = _state!.IlFocusedTreeKey as string; await new Hex1bTerminalInputSequenceBuilder() @@ -757,12 +808,14 @@ await auto.WaitUntilAsync(_ => _state!.IlFocusedTreeKey as string != beforeKey, /// The IL Inspector tab exposes exactly one ScrollPanelNode in the focus ring. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_TreeContainsExactlyOneScrollPanelNode() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SwitchToIlAsync(terminal, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -778,7 +831,7 @@ await auto.WaitUntilAsync(_ => count = focusables.OfType().Count(); return true; }, description: "focus ring observed without a concurrent rebuild"); - Assert.Equal(1, count); + Assert.AreEqual(1, count); _cts!.Cancel(); await runTask; @@ -790,13 +843,14 @@ await auto.WaitUntilAsync(_ => /// that condition. Pure unit assertion — backs up the rendered theory variants /// below by pinning the behavior at the helper level. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Tab3_NoMatchSearch_NavigationHelpers_NoOpOnZeroRows() { IReadOnlyList empty = []; - Assert.Equal(-1, IlTreeList.ResolveEffectiveIndex(empty, key: null)); - Assert.Equal(-1, IlTreeList.ResolveEffectiveIndex(empty, key: "method:0x06000001")); - Assert.Equal(-1, IlTreeList.FindRowIndex(empty, "method:0x06000001")); + Assert.AreEqual(-1, IlTreeList.ResolveEffectiveIndex(empty, key: null)); + Assert.AreEqual(-1, IlTreeList.ResolveEffectiveIndex(empty, key: "method:0x06000001")); + Assert.AreEqual(-1, IlTreeList.FindRowIndex(empty, "method:0x06000001")); } /// @@ -804,14 +858,19 @@ public void Tab3_NoMatchSearch_NavigationHelpers_NoOpOnZeroRows() /// a zero-row tree must be a complete no-op — no exception, no key change, no /// offset change. /// - public static TheoryData NoMatchKeyVariants() => - [ - Hex1bKey.UpArrow, Hex1bKey.DownArrow, - Hex1bKey.Home, Hex1bKey.End, - Hex1bKey.PageUp, Hex1bKey.PageDown, - Hex1bKey.Enter, Hex1bKey.Spacebar, - Hex1bKey.LeftArrow, Hex1bKey.RightArrow, - ]; + public static IEnumerable NoMatchKeyVariants() + { + yield return [Hex1bKey.UpArrow]; + yield return [Hex1bKey.DownArrow]; + yield return [Hex1bKey.Home]; + yield return [Hex1bKey.End]; + yield return [Hex1bKey.PageUp]; + yield return [Hex1bKey.PageDown]; + yield return [Hex1bKey.Enter]; + yield return [Hex1bKey.Spacebar]; + yield return [Hex1bKey.LeftArrow]; + yield return [Hex1bKey.RightArrow]; + } /// /// On a no-match search (zero rows), each navigation key is a no-op against the @@ -819,12 +878,13 @@ public static TheoryData NoMatchKeyVariants() => /// to reach 0 before pressing keys so /// the panel's binding closure is guaranteed to hold the empty rows. /// - [Theory(Timeout = 60_000)] - [MemberData(nameof(NoMatchKeyVariants))] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DynamicData(nameof(NoMatchKeyVariants))] public async Task Tab3_NoMatchSearch_NavigationKey_IsNoOp(Hex1bKey key) { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SwitchToIlAsync(terminal, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -866,8 +926,8 @@ await auto.WaitUntilAsync(s => await auto.KeyAsync(key, ct: ct); await Task.Delay(50, ct); - Assert.Equal(beforeKey, _state.IlFocusedTreeKey as string); - Assert.Equal(beforeOffset, TreeOffset); + Assert.AreEqual(beforeKey, _state.IlFocusedTreeKey as string); + Assert.AreEqual(beforeOffset, TreeOffset); _cts!.Cancel(); await runTask; @@ -876,13 +936,14 @@ await auto.WaitUntilAsync(s => /// /// Mouse wheel on a zero-row tree is a no-op for both offset and selection. /// - [Theory(Timeout = 60_000)] - [InlineData(true)] - [InlineData(false)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow(true)] + [DataRow(false)] public async Task Tab3_NoMatchSearch_MouseWheel_IsNoOp(bool wheelDown) { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SwitchToIlAsync(terminal, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -920,8 +981,8 @@ await auto.WaitUntilAsync(s => await seq.Build().ApplyAsync(terminal, ct); await Task.Delay(50, ct); - Assert.Equal(beforeKey, _state.IlFocusedTreeKey as string); - Assert.Equal(beforeOffset, TreeOffset); + Assert.AreEqual(beforeKey, _state.IlFocusedTreeKey as string); + Assert.AreEqual(beforeOffset, TreeOffset); _cts!.Cancel(); await runTask; @@ -929,12 +990,14 @@ await auto.WaitUntilAsync(s => /// When the row count drops below the previous offset, the panel clamps Offset to MaxOffset. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_RowCountShrinks_ClampsScrollOffset() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -944,14 +1007,30 @@ public async Task Tab3_RowCountShrinks_ClampsScrollOffset() await auto.KeyAsync(Hex1bKey.End, ct: ct); await auto.WaitUntilAsync(_ => TreeOffset == TreeMaxOffset(sp), description: "scrolled to end"); - // Collapse all types — content height shrinks dramatically. - foreach (var t in _state!.Analyzer.TypeDefs) - _state!.IlTreeExpansionState[$"type:{t.FullName}"] = false; - _state!.App.Invalidate(); + // Collapse all types on the render thread. Direct test-thread mutation can + // race the headless render loop and leave the terminal showing the old, + // expanded rows even though the test already requested an invalidate. + var collapseApplied = 0; + _state!.PendingMutations.Enqueue(s => + { + foreach (var t in s.Analyzer.TypeDefs) + s.IlTreeExpansionState[$"type:{t.FullName}"] = false; + + Volatile.Write(ref collapseApplied, 1); + s.App.Invalidate(); + s.RequestExtraFrame(); + }); + _state.App.Invalidate(); + _state.RequestExtraFrame(); - await auto.WaitUntilAsync(_ => TreeOffset <= TreeMaxOffset(sp), + await auto.WaitUntilAsync(_ => + { + _state.App.Invalidate(); + return Volatile.Read(ref collapseApplied) == 1 + && TreeOffset <= TreeMaxOffset(sp); + }, description: "Offset clamped after collapse"); - Assert.True(TreeOffset <= TreeMaxOffset(sp), $"Offset={TreeOffset} MaxOffset={TreeMaxOffset(sp)}"); + Assert.IsLessThanOrEqualTo(TreeMaxOffset(sp), TreeOffset, $"Offset={TreeOffset} MaxOffset={TreeMaxOffset(sp)}"); _cts!.Cancel(); await runTask; @@ -959,12 +1038,14 @@ await auto.WaitUntilAsync(_ => TreeOffset <= TreeMaxOffset(sp), /// A coalesced DownArrow + LeftArrow batch operates on the post-Down selection, not the build-time snapshot. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_DownArrow_LeftArrow_Coalesced_OperatesOnLiveSelection() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll, enableInputCoalescing: true); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll, enableInputCoalescing: true); + var runTask = RunAppAsync(app, ct); await SwitchToIlAsync(terminal, ct); // Expand TWO namespaces so DownArrow walks from one to the next, both initially expanded. @@ -993,9 +1074,9 @@ public async Task Tab3_DownArrow_LeftArrow_Coalesced_OperatesOnLiveSelection() _state!.App.Invalidate(); await auto.WaitUntilAsync(_ => _state!.IlFocusedTreeKey as string == firstNs.Key, description: "first namespace selected"); - Assert.True(Views.IlInspectorView.GetExpansionState(_state, firstNs.ExpansionKey, defaultExpanded: true), + Assert.IsTrue(Views.IlInspectorView.GetExpansionState(_state, firstNs.ExpansionKey, defaultExpanded: true), "first namespace expanded"); - Assert.True(Views.IlInspectorView.GetExpansionState(_state, secondNs.ExpansionKey, defaultExpanded: true), + Assert.IsTrue(Views.IlInspectorView.GetExpansionState(_state, secondNs.ExpansionKey, defaultExpanded: true), "second namespace expanded"); // Coalesced DownArrow + LeftArrow: Down moves to next row (first child of firstNs); @@ -1009,7 +1090,7 @@ await auto.WaitUntilAsync(_ => _state!.IlFocusedTreeKey as string == firstNs.Key await Task.Delay(100, ct); // First namespace must still be expanded (not collapsed by a stale-state Left). - Assert.True(Views.IlInspectorView.GetExpansionState(_state, firstNs.ExpansionKey, defaultExpanded: true), + Assert.IsTrue(Views.IlInspectorView.GetExpansionState(_state, firstNs.ExpansionKey, defaultExpanded: true), "LeftArrow must not collapse the build-time row when selection has moved"); _cts!.Cancel(); @@ -1018,12 +1099,14 @@ await auto.WaitUntilAsync(_ => _state!.IlFocusedTreeKey as string == firstNs.Key /// A click on a row selects and activates that row. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_Click_OnRow_SelectsAndActivates() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -1049,7 +1132,8 @@ public async Task Tab3_Click_OnRow_SelectsAndActivates() .ApplyAsync(terminal, ct); await auto.WaitUntilAsync(_ => _state!.IlFocusedTreeKey as string == rows[visibleMethod].Key, description: "click selected the method row"); - Assert.Equal(rows[visibleMethod].Method!.Token, _state!.IlSelectedMethod?.Token); + Assert.IsNotNull(_state!.IlSelectedMethod); + Assert.AreEqual(rows[visibleMethod].Method!.Token, _state.IlSelectedMethod.Token); _cts!.Cancel(); await runTask; @@ -1057,12 +1141,14 @@ await auto.WaitUntilAsync(_ => _state!.IlFocusedTreeKey as string == rows[visibl /// A click on the scrollbar gutter when scrollable does not change the row selection. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_Click_OnScrollbarColumn_DoesNotSelectRow() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -1085,7 +1171,7 @@ public async Task Tab3_Click_OnScrollbarColumn_DoesNotSelectRow() .ApplyAsync(terminal, ct); await Task.Delay(80, ct); - Assert.Equal(beforeKey, _state!.IlFocusedTreeKey as string); + Assert.AreEqual(beforeKey, _state!.IlFocusedTreeKey as string); _cts!.Cancel(); await runTask; @@ -1093,12 +1179,14 @@ public async Task Tab3_Click_OnScrollbarColumn_DoesNotSelectRow() /// ComplexApp.dll renders the scrollbar correctly when content exceeds the viewport. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_ComplexAppDll_ScrollbarRenders() { - var (terminal, app, ct) = CreateMouseApp(samples.ComplexAppDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.ComplexAppDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -1120,12 +1208,14 @@ await auto.WaitUntilAsync(s => /// NavigateToIlMethod targeting a deep row scrolls that row into view via the pending-scroll flag. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_NavigateToIlMethod_DeepRow_ScrollsIntoView() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); // Stay on the General tab so the IL view does not render until the jump. await new Hex1bTerminalInputSequenceBuilder() @@ -1143,7 +1233,7 @@ public async Task Tab3_NavigateToIlMethod_DeepRow_ScrollsIntoView() { if (rows[i].Kind == IlTreeRowKind.Method) { deepIdx = i; break; } } - Assert.True(deepIdx > 0, "test fixture must contain a method row"); + Assert.IsGreaterThan(0, deepIdx, "test fixture must contain a method row"); var deepMethod = rows[deepIdx].Method!; _state!.NavigateToIlMethod(deepMethod); @@ -1167,12 +1257,14 @@ await auto.WaitUntilAsync(_ => /// Wheel can push the selection offscreen; a subsequent repaint does not snap the viewport back to the selection. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_MouseWheel_ScrollsSelectionOffscreen_RepaintDoesNotSnapBack() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -1207,8 +1299,8 @@ await auto.WaitUntilAsync(_ => TreeOffset >= Math.Min(expectedAdvance, TreeMaxOf _state!.App.Invalidate(); await auto.WaitUntilAsync(_ => true, description: "render frame elapses"); - Assert.Equal(rows[0].Key, _state!.IlFocusedTreeKey as string); - Assert.Equal(offsetAfterWheel, TreeOffset); + Assert.AreEqual(rows[0].Key, _state!.IlFocusedTreeKey as string); + Assert.AreEqual(offsetAfterWheel, TreeOffset); _cts!.Cancel(); await runTask; @@ -1216,12 +1308,14 @@ await auto.WaitUntilAsync(_ => TreeOffset >= Math.Min(expectedAdvance, TreeMaxOf /// NavigateBack landing on the IL tab focuses the ScrollPanelNode (via RequestContentFocus). - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_NavigateBack_FromHexToIl_FocusesScrollPanelNode() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SwitchToIlAsync(terminal, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -1245,12 +1339,14 @@ await auto.WaitUntilAsync(_ => _state!.App.FocusedNode is ScrollPanelNode, /// On first arrival with no focused key, row 0 is the effective selection. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_FirstArrival_NoFocusedKey_HighlightsRow0() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SwitchToIlAsync(terminal, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -1274,12 +1370,14 @@ await auto.WaitUntilAsync(_ => _state!.IlFocusedTreeKey as string == rows[1].Key /// DownArrow from a null focused key lands on row 1 (effective 0 + 1). - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_DownArrow_FromNullKey_LandsOnRow1() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SwitchToIlAsync(terminal, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -1300,12 +1398,14 @@ await auto.WaitUntilAsync(_ => _state!.IlFocusedTreeKey as string == rows[1].Key /// A SetIlFocusedTreeKey(null) clears the pending-scroll flag without leaking to subsequent renders. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_PendingFlag_DoesNotLeakAfterSetIlFocusedTreeKeyNull() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -1335,11 +1435,11 @@ await auto.WaitUntilAsync(_ => !_state.IlScrollSelectionIntoViewPending, .ApplyAsync(terminal, ct); } var offsetAfterWheel = TreeOffset; - Assert.True(offsetAfterWheel > 0, "wheel advanced offset"); + Assert.IsGreaterThan(0, offsetAfterWheel, "wheel advanced offset"); _state!.App.Invalidate(); await Task.Delay(80, ct); - Assert.Equal(offsetAfterWheel, TreeOffset); + Assert.AreEqual(offsetAfterWheel, TreeOffset); _cts!.Cancel(); await runTask; @@ -1347,12 +1447,14 @@ await auto.WaitUntilAsync(_ => !_state.IlScrollSelectionIntoViewPending, /// When the panel is not scrollable, a click in the rightmost column selects the row beneath it. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_Click_RightmostColumn_WhenNotScrollable_SelectsRow() { - var (terminal, app, ct) = CreateMouseApp(samples.HelloWorldDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.HelloWorldDll); + var runTask = RunAppAsync(app, ct); await SwitchToIlAsync(terminal, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -1380,7 +1482,7 @@ await auto.WaitUntilAsync(_ => await auto.WaitUntilAsync(_ => sp.ContentSize == Math.Min(sp.ViewportSize, rows.Count), description: "panel ContentSize agrees with expanded rows"); } - Assert.True(methodIdx >= 0); + Assert.IsGreaterThanOrEqualTo(0, methodIdx); var rightCol = sp.Bounds.X + sp.Bounds.Width - 1; var clickY = sp.Bounds.Y + (methodIdx - TreeOffset); @@ -1397,12 +1499,14 @@ await auto.WaitUntilAsync(_ => _state!.IlFocusedTreeKey as string == rows[method /// An external jump that expands the tree on an already-open IL tab scrolls the deep target into view after the layout grows. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_AlreadyOpen_ExternalJumpExpandsTree_TargetRowScrollsIntoView() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SwitchToIlAsync(terminal, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -1438,12 +1542,14 @@ await auto.WaitUntilAsync(_ => /// Home re-anchors the viewport when row 0 is selected but offscreen (boundary scroll-recovery). - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_Home_WhenSelectedRow0IsOffscreen_ScrollsSelectionIntoView() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -1476,12 +1582,14 @@ await auto.WaitUntilAsync(_ => TreeOffset == 0, /// UpArrow at row 0 re-anchors the viewport when row 0 is offscreen, even though the selection index does not move. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_UpArrow_AtFirstRow_WhenOffscreen_ScrollsSelectionIntoView() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -1506,7 +1614,7 @@ public async Task Tab3_UpArrow_AtFirstRow_WhenOffscreen_ScrollsSelectionIntoView await auto.KeyAsync(Hex1bKey.UpArrow, ct: ct); await auto.WaitUntilAsync(_ => TreeOffset == 0, description: "UpArrow at row 0 re-anchors viewport"); - Assert.Equal(rows[0].Key, _state!.IlFocusedTreeKey as string); + Assert.AreEqual(rows[0].Key, _state!.IlFocusedTreeKey as string); _cts!.Cancel(); await runTask; @@ -1514,12 +1622,14 @@ await auto.WaitUntilAsync(_ => TreeOffset == 0, /// End re-anchors the viewport when the last row is selected but offscreen. - [Fact(Timeout = 60_000)] + [TestMethod] + + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_End_WhenLastRowAlreadySelectedButOffscreen_ScrollsSelectionIntoView() { - var (terminal, app, ct) = CreateMouseApp(samples.RichLibraryDll); - var runTask = app.RunAsync(ct); + var (terminal, app, ct) = CreateMouseApp(Samples.RichLibraryDll); + var runTask = RunAppAsync(app, ct); await SetupIlTabAsync(terminal, ExpandAllTypes, ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -1557,12 +1667,18 @@ await auto.WaitUntilAsync(_ => TreeOffset == TreeMaxOffset(sp), public void Dispose() { + GC.SuppressFinalize(this); _cts?.Cancel(); + if (!TryWaitForAppExit()) + { + _hex1bApp?.Dispose(); + _terminal?.Dispose(); + _ = TryWaitForAppExit(); + } _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); _cts?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/IlInspectorViewTests.cs b/tests/Dotsider.Tests/IlInspectorViewTests.cs index c6ed3c41..444e5832 100644 --- a/tests/Dotsider.Tests/IlInspectorViewTests.cs +++ b/tests/Dotsider.Tests/IlInspectorViewTests.cs @@ -8,9 +8,11 @@ namespace Dotsider.Tests; /// /// Integration tests for the IL Inspector view (Tab 3). /// -[Collection("SampleAssemblies")] -public class IlInspectorViewTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class IlInspectorViewTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _hex1bApp; @@ -19,7 +21,7 @@ public class IlInspectorViewTests(SampleAssemblyFixture samples) : IDisposable private (Hex1bTerminal terminal, Hex1bApp app, CancellationToken ct) CreateDotsiderApp(string dllPath) { - _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + _cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); _workload = new Hex1bAppWorkloadAdapter(); _terminal = Hex1bTerminal.CreateBuilder() .WithWorkload(_workload) @@ -46,10 +48,11 @@ public class IlInspectorViewTests(SampleAssemblyFixture samples) : IDisposable /// After clicking in the IL editor and switching tabs, returning to IL /// must focus the tree table so arrow keys navigate methods, not the editor cursor. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_TreeFocusedOnReturn_AfterEditorHadFocus() { - var (terminal, app, ct) = CreateDotsiderApp(samples.RichLibraryDll); + var (terminal, app, ct) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(ct); // Navigate to IL Inspector tab @@ -109,8 +112,8 @@ await auto.WaitUntilAsync(_ => // Selected method must be preserved after tab round-trip var selectedBefore = _state!.IlSelectedMethod; - Assert.NotNull(selectedBefore); - Assert.Equal("ToTitleCase", selectedBefore!.Name); + Assert.IsNotNull(selectedBefore); + Assert.AreEqual("ToTitleCase", selectedBefore!.Name); // Capture editor cursor before DownArrow var cursorBefore = _state.IlEditorState?.Cursor.Position; @@ -120,7 +123,8 @@ await auto.WaitUntilAsync(_ => await auto.KeyAsync(Hex1bKey.DownArrow, ct: ct); // Editor cursor must not have moved (table consumed the key, not editor) - Assert.Equal(cursorBefore, _state.IlEditorState?.Cursor.Position); + Assert.IsNotNull(_state.IlEditorState); + Assert.AreEqual(cursorBefore, _state.IlEditorState.Cursor.Position); _cts!.Cancel(); await runTask; @@ -130,10 +134,11 @@ await auto.WaitUntilAsync(_ => /// Cross-view NavigateToIlMethod must set the tree table's focused row key /// to the jumped-to method, and the method must be selected. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_CrossViewJump_FocusesTree() { - var (terminal, app, ct) = CreateDotsiderApp(samples.RichLibraryDll); + var (terminal, app, ct) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(ct); // Go to IL tab, select a method programmatically, click in editor @@ -169,30 +174,50 @@ public async Task Tab3_CrossViewJump_FocusesTree() .ApplyAsync(terminal, ct); // Trigger cross-view jump - _state!.PeSubTab = PeSubTabId.MethodDef; var method = _state.Analyzer.MethodDefs.First(m => m.Rva > 0); - _state.NavigateToIlMethod(method); + var navigationApplied = false; + _state.PendingMutations.Enqueue(s => + { + s.PeSubTab = PeSubTabId.MethodDef; + s.NavigateToIlMethod(method); + System.Threading.Volatile.Write(ref navigationApplied, true); + }); + _state.RequestExtraFrame(); // Wait for IL content and for the jump's RequestFocus to be applied var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); await auto.WaitUntilTextAsync("IL_"); + await auto.WaitUntilAsync(_ => System.Threading.Volatile.Read(ref navigationApplied), + description: "cross-view jump mutation applied"); + var stableCount = 0; await auto.WaitUntilAsync(_ => { - try { return _state!.App.FocusedNode is Hex1b.Nodes.ScrollPanelNode; } - catch (NullReferenceException) { return false; } + try + { + if (_state!.App.FocusedNode is Hex1b.Nodes.ScrollPanelNode) + stableCount++; + else + stableCount = 0; + } + catch (NullReferenceException) + { + stableCount = 0; + } + + return stableCount >= 3; }, - description: "focus to return to tree"); + description: "focus stable on tree after cross-view jump"); // The jumped-to method must be selected in state - Assert.Equal(method, _state.IlSelectedMethod); + Assert.AreEqual(method, _state.IlSelectedMethod); // The focused tree key must point to the jumped-to method row - Assert.Equal($"method:{method.Token}", _state.IlFocusedTreeKey); + Assert.AreEqual($"method:{method.Token}", _state.IlFocusedTreeKey); // The method's namespace and type must be expanded var typeDef = _state.Analyzer.TypeDefs.First(t => t.FullName == method.DeclaringType); var ns = !string.IsNullOrEmpty(typeDef.Namespace) ? typeDef.Namespace : "(global)"; - Assert.True(_state.IlTreeExpansionState[$"ns:{ns}"], + Assert.IsTrue(_state.IlTreeExpansionState[$"ns:{ns}"], "Jumped-to method's namespace must be expanded"); - Assert.True(_state.IlTreeExpansionState[$"type:{method.DeclaringType}"], + Assert.IsTrue(_state.IlTreeExpansionState[$"type:{method.DeclaringType}"], "Jumped-to method's type must be expanded"); // Verify focus landed on the tree (not the editor) after the jump. @@ -200,7 +225,7 @@ await auto.WaitUntilAsync(_ => // The Tab3_TreeFocusedOnReturn_AfterEditorHadFocus test covers the // DownArrow-consumed-by-tree behavior separately; here we just verify // the jump set up the correct tree state and focus target. - Assert.True(_state.App.FocusedNode is Hex1b.Nodes.ScrollPanelNode, + Assert.IsTrue(_state.App.FocusedNode is Hex1b.Nodes.ScrollPanelNode, "Focus must be on the tree after cross-view jump"); _cts!.Cancel(); @@ -211,10 +236,11 @@ await auto.WaitUntilAsync(_ => /// Cross-view jump must sync the inner ListNode.SelectedIndex to the jumped-to method row. /// This catches the stale-selection bug where the list stays on row 0 after a jump. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_CrossViewJump_SyncsListNodeSelectedIndex() { - var (terminal, app, ct) = CreateDotsiderApp(samples.RichLibraryDll); + var (terminal, app, ct) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(ct); // Start on IL tab — list selection defaults to row 0 @@ -248,11 +274,10 @@ public async Task Tab3_CrossViewJump_SyncsListNodeSelectedIndex() var rows = Views.IlInspectorView.BuildTreeRows(_state); var expectedKey = $"method:{targetMethod.Token}"; var expectedIndex = rows.FindIndex(r => r.Key == expectedKey); - Assert.True(expectedIndex >= 0, - $"Method {targetMethod.Name} must appear in the flattened tree rows"); + Assert.IsGreaterThanOrEqualTo(0, expectedIndex, $"Method {targetMethod.Name} must appear in the flattened tree rows"); // The actual ListNode.SelectedIndex must match - Assert.Equal(expectedKey, _state.IlFocusedTreeKey); + Assert.AreEqual(expectedKey, _state.IlFocusedTreeKey); _cts!.Cancel(); await runTask; @@ -261,10 +286,11 @@ public async Task Tab3_CrossViewJump_SyncsListNodeSelectedIndex() /// /// RightArrow expands a collapsed namespace/type row and LeftArrow collapses it. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_LeftRightArrow_ExpandCollapseTreeRows() { - var (terminal, app, ct) = CreateDotsiderApp(samples.RichLibraryDll); + var (terminal, app, ct) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(ct); // Go to IL tab @@ -291,7 +317,7 @@ public async Task Tab3_LeftRightArrow_ExpandCollapseTreeRows() .ApplyAsync(terminal, ct); // Type should start collapsed (default) - Assert.False(Views.IlInspectorView.GetExpansionState(_state, typeKey, defaultExpanded: false), + Assert.IsFalse(Views.IlInspectorView.GetExpansionState(_state, typeKey, defaultExpanded: false), "Type should start collapsed"); // Find the first method under this type so we can use its name as a screen-based @@ -306,7 +332,7 @@ public async Task Tab3_LeftRightArrow_ExpandCollapseTreeRows() .Build() .ApplyAsync(terminal, ct); - Assert.True(_state.IlTreeExpansionState.TryGetValue(typeKey, out var expanded) && expanded, + Assert.IsTrue(_state.IlTreeExpansionState.TryGetValue(typeKey, out var expanded) && expanded, "RightArrow must expand the focused type row"); // LeftArrow collapses — child method rows disappear @@ -316,7 +342,7 @@ public async Task Tab3_LeftRightArrow_ExpandCollapseTreeRows() .Build() .ApplyAsync(terminal, ct); - Assert.True(_state.IlTreeExpansionState.TryGetValue(typeKey, out var collapsed) && !collapsed, + Assert.IsTrue(_state.IlTreeExpansionState.TryGetValue(typeKey, out var collapsed) && !collapsed, "LeftArrow must collapse the focused type row"); _cts!.Cancel(); @@ -328,12 +354,12 @@ public async Task Tab3_LeftRightArrow_ExpandCollapseTreeRows() /// public void Dispose() { + GC.SuppressFinalize(this); _cts?.Cancel(); _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); _cts?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/IlNavigationHelperTests.cs b/tests/Dotsider.Tests/IlNavigationHelperTests.cs index 6f7a6ece..bdd53e62 100644 --- a/tests/Dotsider.Tests/IlNavigationHelperTests.cs +++ b/tests/Dotsider.Tests/IlNavigationHelperTests.cs @@ -8,12 +8,14 @@ namespace Dotsider.Tests; /// /// Tests cursor mapping helpers for the IL editor. /// +[TestClass] public sealed class IlNavigationHelperTests { /// /// Verifies a source comment line resolves to the following instruction's Source Link URL. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetSourceLinkUrlAtCursor_SourceCommentLine_ReturnsUrl() { const string url = "https://raw.githubusercontent.com/willibrandon/dotsider/abc/UserService.cs"; @@ -31,13 +33,14 @@ public void GetSourceLinkUrlAtCursor_SourceCommentLine_ReturnsUrl() var actual = IlNavigationHelper.GetSourceLinkUrlAtCursor(editorState, instructions); - Assert.Equal(url, actual); + Assert.AreEqual(url, actual); } /// /// Verifies a source comment line without a marker still resolves to the following instruction's Source Link URL. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetSourceLinkUrlAtCursor_SourceCommentLineWithoutMarker_ReturnsUrl() { const string url = "https://raw.githubusercontent.com/willibrandon/dotsider/abc/UserService.cs"; @@ -55,13 +58,14 @@ public void GetSourceLinkUrlAtCursor_SourceCommentLineWithoutMarker_ReturnsUrl() var actual = IlNavigationHelper.GetSourceLinkUrlAtCursor(editorState, instructions); - Assert.Equal(url, actual); + Assert.AreEqual(url, actual); } /// /// Verifies a hidden source marker line does not resolve as a Source Link target. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetSourceLinkUrlAtCursor_HiddenLine_ReturnsNull() { var editorState = CreateEditorState("// (hidden)\nIL_0000: nop"); @@ -79,13 +83,14 @@ public void GetSourceLinkUrlAtCursor_HiddenLine_ReturnsNull() var actual = IlNavigationHelper.GetSourceLinkUrlAtCursor(editorState, instructions); - Assert.Null(actual); + Assert.IsNull(actual); } /// /// Verifies a source comment line resolves the rendered Source Link marker range when the marker is present. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetSourceLinkYankRangeAtCursor_SourceCommentLineWithMarker_ReturnsMarkerRange() { const string line = "// UserService.cs(1,1)-(1,2) [source link]"; @@ -104,9 +109,9 @@ public void GetSourceLinkYankRangeAtCursor_SourceCommentLineWithMarker_ReturnsMa var actual = IlNavigationHelper.GetSourceLinkYankRangeAtCursor(editorState, instructions); var markerStart = line.IndexOf(IlSourceLinkDecorationProvider.SourceLinkMarker, StringComparison.Ordinal); - Assert.NotNull(actual); - Assert.Equal(new DocumentPosition(1, markerStart + 1), actual.Value.Start); - Assert.Equal( + Assert.IsNotNull(actual); + Assert.AreEqual(new DocumentPosition(1, markerStart + 1), actual.Value.Start); + Assert.AreEqual( new DocumentPosition( 1, markerStart + IlSourceLinkDecorationProvider.SourceLinkMarker.Length + 1), @@ -116,7 +121,8 @@ public void GetSourceLinkYankRangeAtCursor_SourceCommentLineWithMarker_ReturnsMa /// /// Verifies a source comment line without a marker resolves the source range text for yank flash. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetSourceLinkYankRangeAtCursor_SourceCommentLineWithoutMarker_ReturnsSourceRange() { const string sourceRange = "UserService.cs(2,1)-(2,2)"; @@ -134,15 +140,16 @@ public void GetSourceLinkYankRangeAtCursor_SourceCommentLineWithoutMarker_Return var actual = IlNavigationHelper.GetSourceLinkYankRangeAtCursor(editorState, instructions); - Assert.NotNull(actual); - Assert.Equal(new DocumentPosition(1, 4), actual.Value.Start); - Assert.Equal(new DocumentPosition(1, sourceRange.Length + 4), actual.Value.End); + Assert.IsNotNull(actual); + Assert.AreEqual(new DocumentPosition(1, 4), actual.Value.Start); + Assert.AreEqual(new DocumentPosition(1, sourceRange.Length + 4), actual.Value.End); } /// /// Verifies source comment lines do not resolve as instruction lines for go-to-definition. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetInstructionAtCursor_SourceCommentLine_ReturnsNull() { var editorState = CreateEditorState("// UserService.cs(1,1)-(1,2) [source link]\nIL_0000: call Foo::Bar"); @@ -160,7 +167,7 @@ public void GetInstructionAtCursor_SourceCommentLine_ReturnsNull() var actual = IlNavigationHelper.GetInstructionAtCursor(editorState, instructions, headerLineCount: 0); - Assert.Null(actual); + Assert.IsNull(actual); } private static EditorState CreateEditorState(string text) diff --git a/tests/Dotsider.Tests/IlSearchDecorationProviderTests.cs b/tests/Dotsider.Tests/IlSearchDecorationProviderTests.cs index 53046e39..c35eac86 100644 --- a/tests/Dotsider.Tests/IlSearchDecorationProviderTests.cs +++ b/tests/Dotsider.Tests/IlSearchDecorationProviderTests.cs @@ -8,12 +8,14 @@ namespace Dotsider.Tests; /// Tests for which highlights search matches /// in the IL disassembly editor. /// +[TestClass] public class IlSearchDecorationProviderTests { /// /// Verifies null query returns empty. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NullQuery_ReturnsEmpty() { var doc = new Hex1bDocument("hello world"); @@ -21,13 +23,14 @@ public void NullQuery_ReturnsEmpty() var spans = provider.GetDecorations(1, 1, doc); - Assert.Empty(spans); + Assert.IsEmpty(spans); } /// /// Verifies empty query returns empty. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void EmptyQuery_ReturnsEmpty() { var doc = new Hex1bDocument("hello world"); @@ -35,13 +38,14 @@ public void EmptyQuery_ReturnsEmpty() var spans = provider.GetDecorations(1, 1, doc); - Assert.Empty(spans); + Assert.IsEmpty(spans); } /// /// Verifies single match returns a readable match span. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SingleMatch_ReturnsReadableMatchSpan() { var doc = new Hex1bDocument("hello world"); @@ -49,23 +53,24 @@ public void SingleMatch_ReturnsReadableMatchSpan() var spans = provider.GetDecorations(1, 1, doc); - Assert.Single(spans); + Assert.ContainsSingle(spans); var span = spans[0]; // "world" starts at index 6, so 1-based column = 7 - Assert.Equal(new DocumentPosition(1, 7), span.Start); - Assert.Equal(new DocumentPosition(1, 12), span.End); // exclusive end - Assert.Equal(10, span.Priority); - Assert.NotNull(span.Decoration.Background); + Assert.AreEqual(new DocumentPosition(1, 7), span.Start); + Assert.AreEqual(new DocumentPosition(1, 12), span.End); // exclusive end + Assert.AreEqual(10, span.Priority); + Assert.IsNotNull(span.Decoration.Background); AssertColorEquals(HighlightHelper.MatchBgColor, span.Decoration.Background.Value); - Assert.NotNull(span.Decoration.Foreground); + Assert.IsNotNull(span.Decoration.Foreground); AssertColorEquals(HighlightHelper.MatchFgColor, span.Decoration.Foreground.Value); } /// /// Verifies current match returns orange span. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void CurrentMatch_ReturnsOrangeSpan() { var doc = new Hex1bDocument("hello world"); @@ -78,32 +83,34 @@ public void CurrentMatch_ReturnsOrangeSpan() var spans = provider.GetDecorations(1, 1, doc); - Assert.Single(spans); + Assert.ContainsSingle(spans); var span = spans[0]; - Assert.Equal(new DocumentPosition(1, 7), span.Start); - Assert.Equal(new DocumentPosition(1, 12), span.End); - Assert.Equal(20, span.Priority); - Assert.NotNull(span.Decoration.Background); + Assert.AreEqual(new DocumentPosition(1, 7), span.Start); + Assert.AreEqual(new DocumentPosition(1, 12), span.End); + Assert.AreEqual(20, span.Priority); + Assert.IsNotNull(span.Decoration.Background); AssertColorEquals(HighlightHelper.CurrentMatchBgColor, span.Decoration.Background.Value); - Assert.NotNull(span.Decoration.Foreground); + Assert.IsNotNull(span.Decoration.Foreground); AssertColorEquals(HighlightHelper.MatchFgColor, span.Decoration.Foreground.Value); } /// /// Verifies editor search colors clear WCAG AA contrast for normal and current matches. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SearchMatchColors_ClearWcagAa() { - Assert.True(ContrastRatio(HighlightHelper.MatchFgColor, HighlightHelper.MatchBgColor) >= 4.5); - Assert.True(ContrastRatio(HighlightHelper.MatchFgColor, HighlightHelper.CurrentMatchBgColor) >= 4.5); + Assert.IsGreaterThanOrEqualTo(4.5, ContrastRatio(HighlightHelper.MatchFgColor, HighlightHelper.MatchBgColor)); + Assert.IsGreaterThanOrEqualTo(4.5, ContrastRatio(HighlightHelper.MatchFgColor, HighlightHelper.CurrentMatchBgColor)); } /// /// Verifies case insensitive finds match. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void CaseInsensitive_FindsMatch() { var doc = new Hex1bDocument("HELLO"); @@ -111,16 +118,17 @@ public void CaseInsensitive_FindsMatch() var spans = provider.GetDecorations(1, 1, doc); - Assert.Single(spans); + Assert.ContainsSingle(spans); var span = spans[0]; - Assert.Equal(new DocumentPosition(1, 1), span.Start); - Assert.Equal(new DocumentPosition(1, 6), span.End); + Assert.AreEqual(new DocumentPosition(1, 1), span.Start); + Assert.AreEqual(new DocumentPosition(1, 6), span.End); } /// /// Verifies multiple matches per line returns all. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void MultipleMatchesPerLine_ReturnsAll() { var doc = new Hex1bDocument("abc abc abc"); @@ -128,28 +136,29 @@ public void MultipleMatchesPerLine_ReturnsAll() var spans = provider.GetDecorations(1, 1, doc); - Assert.Equal(3, spans.Count); + Assert.HasCount(3, spans); // First match: columns 1-4 - Assert.Equal(new DocumentPosition(1, 1), spans[0].Start); - Assert.Equal(new DocumentPosition(1, 4), spans[0].End); - Assert.Equal(10, spans[0].Priority); + Assert.AreEqual(new DocumentPosition(1, 1), spans[0].Start); + Assert.AreEqual(new DocumentPosition(1, 4), spans[0].End); + Assert.AreEqual(10, spans[0].Priority); // Second match: columns 5-8 - Assert.Equal(new DocumentPosition(1, 5), spans[1].Start); - Assert.Equal(new DocumentPosition(1, 8), spans[1].End); - Assert.Equal(10, spans[1].Priority); + Assert.AreEqual(new DocumentPosition(1, 5), spans[1].Start); + Assert.AreEqual(new DocumentPosition(1, 8), spans[1].End); + Assert.AreEqual(10, spans[1].Priority); // Third match: columns 9-12 - Assert.Equal(new DocumentPosition(1, 9), spans[2].Start); - Assert.Equal(new DocumentPosition(1, 12), spans[2].End); - Assert.Equal(10, spans[2].Priority); + Assert.AreEqual(new DocumentPosition(1, 9), spans[2].Start); + Assert.AreEqual(new DocumentPosition(1, 12), spans[2].End); + Assert.AreEqual(10, spans[2].Priority); } /// /// Verifies no match returns empty. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NoMatch_ReturnsEmpty() { var doc = new Hex1bDocument("hello"); @@ -157,14 +166,14 @@ public void NoMatch_ReturnsEmpty() var spans = provider.GetDecorations(1, 1, doc); - Assert.Empty(spans); + Assert.IsEmpty(spans); } private static void AssertColorEquals(Hex1bColor expected, Hex1bColor actual) { - Assert.Equal(expected.R, actual.R); - Assert.Equal(expected.G, actual.G); - Assert.Equal(expected.B, actual.B); + Assert.AreEqual(expected.R, actual.R); + Assert.AreEqual(expected.G, actual.G); + Assert.AreEqual(expected.B, actual.B); } private static double ContrastRatio(Hex1bColor a, Hex1bColor b) diff --git a/tests/Dotsider.Tests/IlSourceLinkDecorationProviderTests.cs b/tests/Dotsider.Tests/IlSourceLinkDecorationProviderTests.cs index 2f3e9c93..8769f154 100644 --- a/tests/Dotsider.Tests/IlSourceLinkDecorationProviderTests.cs +++ b/tests/Dotsider.Tests/IlSourceLinkDecorationProviderTests.cs @@ -8,12 +8,14 @@ namespace Dotsider.Tests; /// /// Tests for Source Link marker decorations in the IL editor. /// +[TestClass] public sealed class IlSourceLinkDecorationProviderTests { /// /// Verifies Source Link markers are underlined when the instruction has a resolved URL. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetDecorations_SourceLinkMarker_ReturnsUnderlineSpan() { var line = "// UserService.cs(1,1)-(1,2) [source link]"; @@ -34,20 +36,21 @@ public void GetDecorations_SourceLinkMarker_ReturnsUnderlineSpan() var spans = provider.GetDecorations(1, 2, document); - var span = Assert.Single(spans); + var span = Assert.ContainsSingle(spans); var markerStart = line.IndexOf(IlSourceLinkDecorationProvider.SourceLinkMarker, StringComparison.Ordinal); - Assert.Equal(new DocumentPosition(1, markerStart + 1), span.Start); - Assert.Equal(new DocumentPosition( + Assert.AreEqual(new DocumentPosition(1, markerStart + 1), span.Start); + Assert.AreEqual(new DocumentPosition( 1, markerStart + IlSourceLinkDecorationProvider.SourceLinkMarker.Length + 1), span.End); - Assert.Equal(UnderlineStyle.Single, span.Decoration.UnderlineStyle); - Assert.Equal(12, span.Priority); + Assert.AreEqual(UnderlineStyle.Single, span.Decoration.UnderlineStyle); + Assert.AreEqual(12, span.Priority); } /// /// Verifies markers without resolved Source Link URLs are not decorated. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetDecorations_NoSourceLinkUrl_ReturnsEmpty() { var document = new Hex1bDocument("// UserService.cs(1,1)-(1,2) [source link]\nIL_0000: nop"); @@ -66,6 +69,6 @@ public void GetDecorations_NoSourceLinkUrl_ReturnsEmpty() var spans = provider.GetDecorations(1, 2, document); - Assert.Empty(spans); + Assert.IsEmpty(spans); } } diff --git a/tests/Dotsider.Tests/IlSyntaxDecorationProviderTests.cs b/tests/Dotsider.Tests/IlSyntaxDecorationProviderTests.cs index 3bdf5659..91e377ad 100644 --- a/tests/Dotsider.Tests/IlSyntaxDecorationProviderTests.cs +++ b/tests/Dotsider.Tests/IlSyntaxDecorationProviderTests.cs @@ -6,6 +6,7 @@ namespace Dotsider.Tests; /// /// Tests for IL syntax highlighting via decoration spans. /// +[TestClass] public class IlSyntaxDecorationProviderTests { private readonly IlSyntaxDecorationProvider _provider = new(); @@ -13,7 +14,8 @@ public class IlSyntaxDecorationProviderTests /// /// Verifies comment line returns comment span. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void CommentLine_ReturnsCommentSpan() { var line = "// Method: Foo::Bar"; @@ -21,44 +23,46 @@ public void CommentLine_ReturnsCommentSpan() var spans = _provider.GetDecorations(1, 1, doc); - var span = Assert.Single(spans); - Assert.Equal(IlColorizer.CommentColor, span.Decoration.Foreground); - Assert.Equal(1, span.Start.Line); - Assert.Equal(1, span.Start.Column); - Assert.Equal(1, span.End.Line); - Assert.Equal(line.Length + 1, span.End.Column); + var span = Assert.ContainsSingle(spans); + Assert.AreEqual(IlColorizer.CommentColor, span.Decoration.Foreground); + Assert.AreEqual(1, span.Start.Line); + Assert.AreEqual(1, span.Start.Column); + Assert.AreEqual(1, span.End.Line); + Assert.AreEqual(line.Length + 1, span.End.Column); } /// /// Verifies instruction line returns address and opcode spans. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void InstructionLine_ReturnsAddressAndOpcodeSpans() { var doc = new Hex1bDocument("IL_0000: nop"); var spans = _provider.GetDecorations(1, 1, doc); - Assert.Equal(2, spans.Count); + Assert.HasCount(2, spans); // Address span: "IL_0000:" (columns 1..9, end exclusive at 9) var address = spans[0]; - Assert.Equal(IlColorizer.AddressColor, address.Decoration.Foreground); - Assert.Equal(1, address.Start.Column); + Assert.AreEqual(IlColorizer.AddressColor, address.Decoration.Foreground); + Assert.AreEqual(1, address.Start.Column); // separatorIndex for "IL_0000: nop" is 7, so end column = 7 + 2 = 9 - Assert.Equal(9, address.End.Column); + Assert.AreEqual(9, address.End.Column); // Opcode span: "nop" (starts at column 10, length 3, end exclusive at 13) var opcode = spans[1]; - Assert.Equal(IlColorizer.OpcodeColor, opcode.Decoration.Foreground); - Assert.Equal(10, opcode.Start.Column); - Assert.Equal(13, opcode.End.Column); + Assert.AreEqual(IlColorizer.OpcodeColor, opcode.Decoration.Foreground); + Assert.AreEqual(10, opcode.Start.Column); + Assert.AreEqual(13, opcode.End.Column); } /// /// Verifies instruction with operand includes opcode only. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void InstructionWithOperand_IncludesOpcodeOnly() { var line = "IL_0007: callvirt System.String::IsNullOrEmpty"; @@ -67,27 +71,28 @@ public void InstructionWithOperand_IncludesOpcodeOnly() var spans = _provider.GetDecorations(1, 1, doc); // Address + opcode only; no string operand, so no string span - Assert.Equal(2, spans.Count); + Assert.HasCount(2, spans); var address = spans[0]; - Assert.Equal(IlColorizer.AddressColor, address.Decoration.Foreground); + Assert.AreEqual(IlColorizer.AddressColor, address.Decoration.Foreground); var opcode = spans[1]; - Assert.Equal(IlColorizer.OpcodeColor, opcode.Decoration.Foreground); + Assert.AreEqual(IlColorizer.OpcodeColor, opcode.Decoration.Foreground); // "callvirt" starts at column 10 (after "IL_0007: ") - Assert.Equal(10, opcode.Start.Column); + Assert.AreEqual(10, opcode.Start.Column); // "callvirt" is 8 chars, so end column = 10 + 8 = 18 - Assert.Equal(18, opcode.End.Column); + Assert.AreEqual(18, opcode.End.Column); // No string-colored span - Assert.DoesNotContain(spans, s => Equals(s.Decoration.Foreground, IlColorizer.StringColor)); + Assert.DoesNotContain(s => Equals(s.Decoration.Foreground, IlColorizer.StringColor), spans); } /// /// Verifies string operand returns string span. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void StringOperand_ReturnsStringSpan() { var line = "IL_000A: ldstr \"hello world\""; @@ -96,28 +101,29 @@ public void StringOperand_ReturnsStringSpan() var spans = _provider.GetDecorations(1, 1, doc); // Address + opcode + string = 3 spans - Assert.Equal(3, spans.Count); + Assert.HasCount(3, spans); var address = spans[0]; - Assert.Equal(IlColorizer.AddressColor, address.Decoration.Foreground); + Assert.AreEqual(IlColorizer.AddressColor, address.Decoration.Foreground); var opcode = spans[1]; - Assert.Equal(IlColorizer.OpcodeColor, opcode.Decoration.Foreground); + Assert.AreEqual(IlColorizer.OpcodeColor, opcode.Decoration.Foreground); var str = spans[2]; - Assert.Equal(IlColorizer.StringColor, str.Decoration.Foreground); + Assert.AreEqual(IlColorizer.StringColor, str.Decoration.Foreground); // "hello world" with quotes starts at index 15 in the line → column 16 var quoteStart = line.IndexOf('"'); var quoteEnd = line.LastIndexOf('"'); - Assert.Equal(quoteStart + 1, str.Start.Column); // 1-based - Assert.Equal(quoteEnd + 2, str.End.Column); // exclusive end + Assert.AreEqual(quoteStart + 1, str.Start.Column); // 1-based + Assert.AreEqual(quoteEnd + 2, str.End.Column); // exclusive end } /// /// Verifies locals init line returns directive span. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void LocalsInitLine_ReturnsDirectiveSpan() { var line = " .locals init ("; @@ -125,29 +131,31 @@ public void LocalsInitLine_ReturnsDirectiveSpan() var spans = _provider.GetDecorations(1, 1, doc); - var span = Assert.Single(spans); - Assert.Equal(IlColorizer.DirectiveColor, span.Decoration.Foreground); - Assert.Equal(5, span.Start.Column); - Assert.Equal(17, span.End.Column); + var span = Assert.ContainsSingle(spans); + Assert.AreEqual(IlColorizer.DirectiveColor, span.Decoration.Foreground); + Assert.AreEqual(5, span.Start.Column); + Assert.AreEqual(17, span.End.Column); } /// /// Verifies blank line no spans. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void BlankLine_NoSpans() { var doc = new Hex1bDocument(" "); var spans = _provider.GetDecorations(1, 1, doc); - Assert.Empty(spans); + Assert.IsEmpty(spans); } /// /// Verifies viewport range respects start and end. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ViewportRange_RespectsStartAndEnd() { var doc = new Hex1bDocument("// line1\nIL_0000: nop\n// line3\nIL_0001: ret"); @@ -157,20 +165,21 @@ public void ViewportRange_RespectsStartAndEnd() // Line 2 produces address + opcode = 2 spans // Line 3 produces comment = 1 span - Assert.Equal(3, spans.Count); + Assert.HasCount(3, spans); // All spans should be on lines 2 or 3 - Assert.All(spans, s => Assert.InRange(s.Start.Line, 2, 3)); + TestAssert.All(spans, s => Assert.IsInRange(2, 3, s.Start.Line)); // Line 1 and line 4 should not appear - Assert.DoesNotContain(spans, s => s.Start.Line == 1); - Assert.DoesNotContain(spans, s => s.Start.Line == 4); + Assert.DoesNotContain(s => s.Start.Line == 1, spans); + Assert.DoesNotContain(s => s.Start.Line == 4, spans); } /// /// Verifies non il line comment metadata returns comment span. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NonIlLine_CommentMetadata_ReturnsCommentSpan() { var line = "// Max stack: 5"; @@ -178,9 +187,9 @@ public void NonIlLine_CommentMetadata_ReturnsCommentSpan() var spans = _provider.GetDecorations(1, 1, doc); - var span = Assert.Single(spans); - Assert.Equal(IlColorizer.CommentColor, span.Decoration.Foreground); - Assert.Equal(1, span.Start.Column); - Assert.Equal(line.Length + 1, span.End.Column); + var span = Assert.ContainsSingle(spans); + Assert.AreEqual(IlColorizer.CommentColor, span.Decoration.Foreground); + Assert.AreEqual(1, span.Start.Column); + Assert.AreEqual(line.Length + 1, span.End.Column); } } diff --git a/tests/Dotsider.Tests/IlTreeVirtualizationTests.cs b/tests/Dotsider.Tests/IlTreeVirtualizationTests.cs index d892578c..b14e8788 100644 --- a/tests/Dotsider.Tests/IlTreeVirtualizationTests.cs +++ b/tests/Dotsider.Tests/IlTreeVirtualizationTests.cs @@ -16,9 +16,11 @@ namespace Dotsider.Tests; /// and (the /// real capture/pending logic) over a synthetic 12,000-row tree. /// -[Collection("SampleAssemblies")] -public sealed class IlTreeVirtualizationTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public sealed class IlTreeVirtualizationTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private const int RowCount = 12_000; private Hex1bApp? _app; @@ -43,7 +45,7 @@ public sealed class IlTreeVirtualizationTests(SampleAssemblyFixture samples) : I /// private (Hex1bTerminalAutomator auto, Task runTask, CancellationToken ct) StartTreeApp() { - _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + _cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); _workload = new Hex1bAppWorkloadAdapter(); _terminal = Hex1bTerminal.CreateBuilder() .WithWorkload(_workload) @@ -57,14 +59,17 @@ public sealed class IlTreeVirtualizationTests(SampleAssemblyFixture samples) : I _app = new Hex1bApp(_ => { - _state ??= new DotsiderState(_app!, samples.HelloWorldDll) { CurrentTab = TabId.IlInspector }; + _state ??= new DotsiderState(_app!, Samples.HelloWorldDll) { CurrentTab = TabId.IlInspector }; // Mirror DotsiderApp.Build: only the root build advances the generation. unchecked { _state.BuildGeneration++; } _state.ExtraFrameArmed = false; IlInspectorView.SyncTreeScroll(_state, _rows!); var tree = IlTreeList.Build( - _rows!, formatted, _state, - selectionChanged: i => _state!.IlFocusedTreeKey = _rows![i].Key, + _rows!, + formatted, + getRows: () => _rows!, + state: _state, + selectionChanged: row => _state!.IlFocusedTreeKey = row.Key, itemActivated: null, expandRow: null, collapseRow: null); return Task.FromResult(new VStackWidget( [ @@ -94,6 +99,12 @@ await auto.WaitUntilAsync(_ => _app.FocusedNode is ScrollPanelNode, return sp!; } + private void InvalidateAfterDirectStateMutation() + { + _state!.App.Invalidate(); + _state.RequestExtraFrame(); + } + /// The gutter column: the panel's rightmost column when the tree overflows. private static int GutterCol(ScrollPanelNode sp) => sp.Bounds.X + sp.Bounds.Width - 1; @@ -113,18 +124,21 @@ private static int FirstThumbY(Hex1bTerminalSnapshot snapshot, ScrollPanelNode s /// Past the 10k render-surface clamp, scrolling to the very bottom still paints the /// last rows — the full viewport, no blank gap. Pins the issue #188 regression. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Rows12k_ScrolledToBottom_RendersLastRows_NoGap() { var (auto, runTask, _) = StartTreeApp(); var sp = await WaitForPanelAsync(auto); _state!.IlTreeScrollOffset = int.MaxValue; // clamps to MaxOffset in the next build - _state.App.Invalidate(); + InvalidateAfterDirectStateMutation(); - await auto.WaitUntilTextAsync("row-11999"); var expectedTop = RowCount - sp.ViewportSize; - Assert.Equal(expectedTop, _state.IlTreeScrollOffset); + await auto.WaitUntilAsync(_ => _state!.IlTreeScrollOffset == expectedTop, + description: "scroll offset clamps to bottom"); + await auto.WaitUntilTextAsync("row-11999"); + Assert.AreEqual(expectedTop, _state.IlTreeScrollOffset); // The viewport is full from its first row — a blank gap would drop this label. await auto.WaitUntilTextAsync($"row-{expectedTop}"); @@ -136,7 +150,8 @@ public async Task Rows12k_ScrolledToBottom_RendersLastRows_NoGap() /// The gutter thumb sits at the top at offset zero and moves to the gutter's end at /// MaxOffset — the scrollbar math spans the full 12,000 rows. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Rows12k_GutterThumb_TracksOffset_EndToEnd() { var (auto, runTask, _) = StartTreeApp(); @@ -145,10 +160,10 @@ public async Task Rows12k_GutterThumb_TracksOffset_EndToEnd() var topThumb = -1; await auto.WaitUntilAsync(s => (topThumb = FirstThumbY(s, sp)) >= 0, description: "thumb painted at offset 0"); - Assert.Equal(sp.Bounds.Y, topThumb); + Assert.AreEqual(sp.Bounds.Y, topThumb); _state!.IlTreeScrollOffset = RowCount; // clamps to MaxOffset - _state.App.Invalidate(); + InvalidateAfterDirectStateMutation(); await auto.WaitUntilAsync(s => { var y = FirstThumbY(s, sp); @@ -163,14 +178,15 @@ await auto.WaitUntilAsync(s => /// Wheel over the tree body advances the offset by 3 without changing the selection, /// even when the selected row scrolls offscreen — the #167 decoupling at 12k rows. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Rows12k_Wheel_MovesViewport_NotSelection() { var (auto, runTask, ct) = StartTreeApp(); var sp = await WaitForPanelAsync(auto); _state!.IlFocusedTreeKey = "row:0"; - _state.App.Invalidate(); + InvalidateAfterDirectStateMutation(); await new Hex1bTerminalInputSequenceBuilder() .MouseMoveTo(sp.Bounds.X + 5, sp.Bounds.Y + 5) @@ -180,7 +196,7 @@ public async Task Rows12k_Wheel_MovesViewport_NotSelection() await auto.WaitUntilAsync(_ => _state.IlTreeScrollOffset == 3, description: "wheel advanced offset by exactly 3"); - Assert.Equal("row:0", _state.IlFocusedTreeKey as string); + Assert.AreEqual("row:0", _state.IlFocusedTreeKey as string); _cts!.Cancel(); await runTask; @@ -190,7 +206,8 @@ await auto.WaitUntilAsync(_ => _state.IlTreeScrollOffset == 3, /// End selects the last of 12,000 rows, scrolls it into view, and renders it; a /// further DownArrow does not wrap. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Rows12k_End_SelectsLastRow_ScrollsIntoView_NoWrap() { var (auto, runTask, ct) = StartTreeApp(); @@ -200,11 +217,11 @@ public async Task Rows12k_End_SelectsLastRow_ScrollsIntoView_NoWrap() await auto.WaitUntilAsync(_ => _state!.IlFocusedTreeKey as string == "row:11999", description: "End selects the last row"); await auto.WaitUntilTextAsync("row-11999"); - Assert.Equal(RowCount - sp.ViewportSize, _state!.IlTreeScrollOffset); + Assert.AreEqual(RowCount - sp.ViewportSize, _state!.IlTreeScrollOffset); await auto.KeyAsync(Hex1bKey.DownArrow, ct: ct); await Task.Delay(50, ct); - Assert.Equal("row:11999", _state.IlFocusedTreeKey as string); + Assert.AreEqual("row:11999", _state.IlFocusedTreeKey as string); _cts!.Cancel(); await runTask; @@ -214,14 +231,15 @@ await auto.WaitUntilAsync(_ => _state!.IlFocusedTreeKey as string == "row:11999" /// A click selects the row's absolute index — viewport row plus the scroll offset — /// under a deep offset where windowing bugs would select the wrong row. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Rows12k_Click_SelectsAbsoluteRow_UnderDeepOffset() { var (auto, runTask, ct) = StartTreeApp(); var sp = await WaitForPanelAsync(auto); _state!.IlTreeScrollOffset = 11_500; - _state.App.Invalidate(); + InvalidateAfterDirectStateMutation(); await auto.WaitUntilTextAsync("row-11500"); await new Hex1bTerminalInputSequenceBuilder() @@ -239,7 +257,8 @@ await auto.WaitUntilAsync(_ => _state.IlFocusedTreeKey as string == "row:11505", /// An external jump to a deep key (the pending-scroll path used by cross-view /// navigation and search) lands the row inside the rendered viewport. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Rows12k_PendingScroll_DeepKey_LandsInViewport() { var (auto, runTask, _) = StartTreeApp(); @@ -249,8 +268,7 @@ public async Task Rows12k_PendingScroll_DeepKey_LandsInViewport() await auto.WaitUntilAsync(_ => !_state.IlScrollSelectionIntoViewPending, description: "pending scroll consumed"); await auto.WaitUntilTextAsync("row-11500"); - Assert.InRange(11_500, - _state.IlTreeScrollOffset, _state.IlTreeScrollOffset + sp.ViewportSize - 1); + Assert.IsInRange(_state.IlTreeScrollOffset, _state.IlTreeScrollOffset + sp.ViewportSize - 1, 11_500); _cts!.Cancel(); await runTask; @@ -260,7 +278,8 @@ await auto.WaitUntilAsync(_ => !_state.IlScrollSelectionIntoViewPending, /// A track click below the thumb pages the viewport by one page (viewport − 1) and /// leaves the selection alone — the hand-rolled gutter matches the old panel gutter. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Rows12k_GutterTrackClick_PagesViewport_WithoutChangingSelection() { var (auto, runTask, ct) = StartTreeApp(); @@ -275,7 +294,7 @@ public async Task Rows12k_GutterTrackClick_PagesViewport_WithoutChangingSelectio await auto.WaitUntilAsync(_ => _state.IlTreeScrollOffset == expected, description: "track click pages the viewport"); - Assert.Equal(beforeKey, _state.IlFocusedTreeKey as string); + Assert.AreEqual(beforeKey, _state.IlFocusedTreeKey as string); _cts!.Cancel(); await runTask; @@ -285,7 +304,8 @@ await auto.WaitUntilAsync(_ => _state.IlTreeScrollOffset == expected, /// Dragging the thumb scrolls proportionally across all 12,000 rows without changing /// the selection, and the panel keeps keyboard focus through the drag. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Rows12k_GutterThumbDrag_UpdatesOffset_KeepsSelectionAndFocus() { var (auto, runTask, ct) = StartTreeApp(); @@ -303,7 +323,7 @@ await auto.WaitUntilAsync(s => (thumbY = FirstThumbY(s, sp)) >= 0, await auto.WaitUntilAsync(_ => _state.IlTreeScrollOffset > 0, description: "thumb drag advanced the offset"); - Assert.Equal(beforeKey, _state.IlFocusedTreeKey as string); + Assert.AreEqual(beforeKey, _state.IlFocusedTreeKey as string); // The panel must still own the keyboard: DownArrow moves the selection. await auto.KeyAsync(Hex1bKey.DownArrow, ct: ct); @@ -320,7 +340,8 @@ await auto.WaitUntilAsync(_ => _state.IlFocusedTreeKey as string != beforeKey, /// the viewport verifier must re-clamp the offset and refill the viewport without /// any further input. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Rows12k_ViewportGrows_WindowRefills_WithoutInput() { _headerRows = 1; @@ -329,13 +350,13 @@ public async Task Rows12k_ViewportGrows_WindowRefills_WithoutInput() var oldViewport = sp.ViewportSize; _state!.IlTreeScrollOffset = int.MaxValue; // clamps to MaxOffset for the old height - _state.App.Invalidate(); + InvalidateAfterDirectStateMutation(); await auto.WaitUntilTextAsync("row-11999"); - Assert.Equal(RowCount - oldViewport, _state.IlTreeScrollOffset); + Assert.AreEqual(RowCount - oldViewport, _state.IlTreeScrollOffset); // Grow the pane by removing the header; no input follows. _headerRows = 0; - _state.App.Invalidate(); + InvalidateAfterDirectStateMutation(); await auto.WaitUntilAsync(_ => sp.ViewportSize == oldViewport + 1 @@ -355,14 +376,15 @@ await auto.WaitUntilAsync(_ => /// the tree sync would make a nudger armed concurrently from a socket thread /// believe a later build already ran and exit without nudging. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SyncTreeScroll_DoesNotAdvanceBuildGeneration() { _workload = new Hex1bAppWorkloadAdapter(); _terminal = Hex1bTerminal.CreateBuilder().WithWorkload(_workload).WithHeadless().WithDimensions(80, 24).Build(); _app = new Hex1bApp(_ => Task.FromResult(new TextBlockWidget("t")), new Hex1bAppOptions { WorkloadAdapter = _workload }); - _state = new DotsiderState(_app, samples.HelloWorldDll) + _state = new DotsiderState(_app, Samples.HelloWorldDll) { BuildGeneration = 41, ExtraFrameArmed = true @@ -370,8 +392,8 @@ public void SyncTreeScroll_DoesNotAdvanceBuildGeneration() IlInspectorView.SyncTreeScroll(_state, []); - Assert.Equal(41, _state.BuildGeneration); - Assert.True(_state.ExtraFrameArmed); + Assert.AreEqual(41, _state.BuildGeneration); + Assert.IsTrue(_state.ExtraFrameArmed); } /// diff --git a/tests/Dotsider.Tests/IlcNameDemanglerTests.cs b/tests/Dotsider.Tests/IlcNameDemanglerTests.cs index b491e8d3..ba25ddfd 100644 --- a/tests/Dotsider.Tests/IlcNameDemanglerTests.cs +++ b/tests/Dotsider.Tests/IlcNameDemanglerTests.cs @@ -8,6 +8,7 @@ namespace Dotsider.Tests; /// to managed names, and the classification of compiler-generated symbols. Cases are pinned to /// real symbol spellings observed in the NativeAotConsole fixture PDB. /// +[TestClass] public class IlcNameDemanglerTests { private static IlcNameDemangler Build(params RecoveredType[] types) => new(types); @@ -17,16 +18,17 @@ public class IlcNameDemanglerTests /// assembly NativeAotConsole, method <Main>$ → symbol /// NativeAotConsole_Program___Main__. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Demangle_EntryPoint_JoinsExactly() { var d = Build(new RecoveredType("Program", ["
$"], "NativeAotConsole")); var result = d.Demangle("NativeAotConsole_Program___Main__"); - Assert.Equal("Program.
$", result.ManagedName); - Assert.Equal(NativeSymbolKind.Function, result.Kind); - Assert.True(result.IsExactMatch); + Assert.AreEqual("Program.
$", result.ManagedName); + Assert.AreEqual(NativeSymbolKind.Function, result.Kind); + Assert.IsTrue(result.IsExactMatch); } /// @@ -34,94 +36,100 @@ public void Demangle_EntryPoint_JoinsExactly() /// instantiation scope joins after the scope is stripped: /// S_P_CoreLib_System_ReadOnlySpan_1<Char>__ToString. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Demangle_GenericInstanceMethod_JoinsAfterScopeStrip() { var d = Build(new RecoveredType("System.ReadOnlySpan`1", ["ToString"], "System.Private.CoreLib")); var result = d.Demangle("S_P_CoreLib_System_ReadOnlySpan_1__ToString"); - Assert.Equal("System.ReadOnlySpan`1.ToString", result.ManagedName); - Assert.True(result.IsExactMatch); + Assert.AreEqual("System.ReadOnlySpan`1.ToString", result.ManagedName); + Assert.IsTrue(result.IsExactMatch); } /// /// Verifies a Windows vtable symbol classifies as a MethodTable and names its type. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Demangle_WindowsVtable_ClassifiesAsMethodTable() { var d = Build(new RecoveredType("System.Collections.Generic.StringEqualityComparer", [], "System.Private.CoreLib")); var result = d.Demangle("??_7S_P_CoreLib_System_Collections_Generic_StringEqualityComparer@@6B@"); - Assert.Equal(NativeSymbolKind.MethodTable, result.Kind); - Assert.Equal("System.Collections.Generic.StringEqualityComparer (MethodTable)", result.ManagedName); - Assert.True(result.IsExactMatch); + Assert.AreEqual(NativeSymbolKind.MethodTable, result.Kind); + Assert.AreEqual("System.Collections.Generic.StringEqualityComparer (MethodTable)", result.ManagedName); + Assert.IsTrue(result.IsExactMatch); } /// /// Verifies a Unix vtable symbol strips its Itanium decimal length prefix and classifies as /// a MethodTable. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Demangle_UnixVtable_StripsLengthPrefix() { var d = Build(new RecoveredType("System.Object", [".ctor"], "System.Private.CoreLib")); var result = d.Demangle("_ZTV20S_P_CoreLib_System_Object"); - Assert.Equal(NativeSymbolKind.MethodTable, result.Kind); - Assert.Equal("System.Object (MethodTable)", result.ManagedName); + Assert.AreEqual(NativeSymbolKind.MethodTable, result.Kind); + Assert.AreEqual("System.Object (MethodTable)", result.ManagedName); } /// /// Verifies the node-level data and stub prefixes classify without needing a metadata join. /// - [Theory(Timeout = 30_000)] - [InlineData("?__GCSTATICS@S_P_CoreLib_System_Text_EncoderReplacementFallback@@", NativeSymbolKind.Statics)] - [InlineData("__NONGCSTATICS_SomeType", NativeSymbolKind.Statics)] - [InlineData("__TypeThreadStaticIndex_SomeType", NativeSymbolKind.Statics)] - [InlineData("__GenericDict_S_P_CoreLib_System_Array__Resize", NativeSymbolKind.GenericDictionary)] - [InlineData("__writableDataString", NativeSymbolKind.Data)] - [InlineData("__readonlydata_SomeMethod", NativeSymbolKind.Data)] - [InlineData("_MyModule__Str_48656C6C6F", NativeSymbolKind.FrozenObject)] - [InlineData("__unbox_SomeType", NativeSymbolKind.Stub)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow("?__GCSTATICS@S_P_CoreLib_System_Text_EncoderReplacementFallback@@", NativeSymbolKind.Statics)] + [DataRow("__NONGCSTATICS_SomeType", NativeSymbolKind.Statics)] + [DataRow("__TypeThreadStaticIndex_SomeType", NativeSymbolKind.Statics)] + [DataRow("__GenericDict_S_P_CoreLib_System_Array__Resize", NativeSymbolKind.GenericDictionary)] + [DataRow("__writableDataString", NativeSymbolKind.Data)] + [DataRow("__readonlydata_SomeMethod", NativeSymbolKind.Data)] + [DataRow("_MyModule__Str_48656C6C6F", NativeSymbolKind.FrozenObject)] + [DataRow("__unbox_SomeType", NativeSymbolKind.Stub)] public void Demangle_NodePrefixes_Classify(string symbol, NativeSymbolKind expected) { var result = Build().Demangle(symbol); - Assert.Equal(expected, result.Kind); + Assert.AreEqual(expected, result.Kind); } /// /// Verifies a nested type (compiler <>c display class) is underscore-joined, not /// angle-bracketed: <>c sanitizes to __c and joins as a nested type. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Demangle_NestedDisplayClass_UnderscoreJoined() { var d = Build(new RecoveredType("System.Foo+<>c", ["b__0_0"], "System.Private.CoreLib")); var result = d.Demangle("S_P_CoreLib_System_Foo___c___Bar_b__0_0"); - Assert.Equal("System.Foo+<>c.b__0_0", result.ManagedName); - Assert.True(result.IsExactMatch); + Assert.AreEqual("System.Foo+<>c.b__0_0", result.ManagedName); + Assert.IsTrue(result.IsExactMatch); } /// /// Verifies a known type whose method is absent from sparse metadata claims no managed /// name — heuristics never populate ManagedName; the raw name stays the display. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Demangle_KnownTypeUnknownMethod_ClaimsNoManagedName() { var d = Build(new RecoveredType("System.String", [], "System.Private.CoreLib")); var result = d.Demangle("S_P_CoreLib_System_String__SomeMissingMethod"); - Assert.Null(result.ManagedName); - Assert.False(result.IsExactMatch); + Assert.IsNull(result.ManagedName); + Assert.IsFalse(result.IsExactMatch); } /// @@ -129,37 +137,40 @@ public void Demangle_KnownTypeUnknownMethod_ClaimsNoManagedName() /// symbol: the shared name is kept as the display, but no signature can say which overload /// the symbol is. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Demangle_DuplicateMethodName_SharedNameNeverExact() { var d = Build(new RecoveredType("System.Foo", ["Bar", "Bar"], "System.Private.CoreLib")); var result = d.Demangle("S_P_CoreLib_System_Foo__Bar"); - Assert.Equal("System.Foo.Bar", result.ManagedName); - Assert.False(result.IsExactMatch); + Assert.AreEqual("System.Foo.Bar", result.ManagedName); + Assert.IsFalse(result.IsExactMatch); } /// /// Verifies an overload disambiguation suffix (_0) that metadata cannot distinguish /// resolves to the base method name, marked non-exact. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Demangle_OverloadSuffix_ResolvesNonExact() { var d = Build(new RecoveredType("System.Foo", ["Bar"], "System.Private.CoreLib")); var result = d.Demangle("S_P_CoreLib_System_Foo__Bar_0"); - Assert.Equal("System.Foo.Bar", result.ManagedName); - Assert.False(result.IsExactMatch); + Assert.AreEqual("System.Foo.Bar", result.ManagedName); + Assert.IsFalse(result.IsExactMatch); } /// /// Verifies a name colliding after sanitization is not claimed as an exact match: two /// distinct methods that sanitize to the same key mark the key ambiguous. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Demangle_SanitizationCollision_NotExact() { // "op.Add" and "op+Add" both sanitize to "op_Add". @@ -168,33 +179,35 @@ public void Demangle_SanitizationCollision_NotExact() var result = d.Demangle("App_T__op_Add"); // Ambiguous key → no confident managed name via the exact path. - Assert.False(result.IsExactMatch); + Assert.IsFalse(result.IsExactMatch); } /// /// Verifies an unrecognized symbol yields no managed name but is not misclassified. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Demangle_Unknown_YieldsNoManagedName() { var result = Build().Demangle("Totally_Unknown_Symbol__Xyz"); - Assert.Null(result.ManagedName); - Assert.False(result.IsExactMatch); + Assert.IsNull(result.ManagedName); + Assert.IsFalse(result.IsExactMatch); } /// /// Verifies the sanitizer matches ILC's rules: letters/underscore pass, a leading digit is /// prefixed, and every other character (including a multibyte codepoint) becomes one _. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Sanitize_MatchesIlcRules() { - Assert.Equal("Foo_Bar", IlcNameDemangler.Sanitize("Foo.Bar")); - Assert.Equal("_1Type", IlcNameDemangler.Sanitize("1Type")); - Assert.Equal("List_1", IlcNameDemangler.Sanitize("List`1")); - Assert.Equal("_Main__", IlcNameDemangler.Sanitize("
$")); - Assert.Equal("a_b", IlcNameDemangler.Sanitize("aéb")); // é → one underscore - Assert.Equal("x_y", IlcNameDemangler.Sanitize("x\U0001F600y")); // emoji (surrogate pair) → one underscore + Assert.AreEqual("Foo_Bar", IlcNameDemangler.Sanitize("Foo.Bar")); + Assert.AreEqual("_1Type", IlcNameDemangler.Sanitize("1Type")); + Assert.AreEqual("List_1", IlcNameDemangler.Sanitize("List`1")); + Assert.AreEqual("_Main__", IlcNameDemangler.Sanitize("
$")); + Assert.AreEqual("a_b", IlcNameDemangler.Sanitize("aéb")); // é → one underscore + Assert.AreEqual("x_y", IlcNameDemangler.Sanitize("x\U0001F600y")); // emoji (surrogate pair) → one underscore } } diff --git a/tests/Dotsider.Tests/InfoDecorationProviderTests.cs b/tests/Dotsider.Tests/InfoDecorationProviderTests.cs index 0cb097ec..4a49d3b8 100644 --- a/tests/Dotsider.Tests/InfoDecorationProviderTests.cs +++ b/tests/Dotsider.Tests/InfoDecorationProviderTests.cs @@ -7,12 +7,13 @@ namespace Dotsider.Tests; /// /// Tests for Info Decoration Provider. /// +[TestClass] public class InfoDecorationProviderTests { /// /// Verifies info label colors label before colon. /// - [Fact] + [TestMethod] public void InfoLabel_ColorsLabelBeforeColon() { var provider = new InfoLabelDecorationProvider(); @@ -20,34 +21,34 @@ public void InfoLabel_ColorsLabelBeforeColon() var spans = provider.GetDecorations(1, 2, doc); - Assert.Equal(2, spans.Count); + Assert.HasCount(2, spans); // First span covers " Assembly Name:" on line 1 - Assert.Equal(1, spans[0].Start.Line); - Assert.Equal(1, spans[0].Start.Column); + Assert.AreEqual(1, spans[0].Start.Line); + Assert.AreEqual(1, spans[0].Start.Column); // Second span covers " Version:" on line 2 - Assert.Equal(2, spans[1].Start.Line); + Assert.AreEqual(2, spans[1].Start.Line); } /// /// Verifies callers can supply a contrast-safe label color for popup surfaces. /// - [Fact] + [TestMethod] public void InfoLabel_UsesCustomLabelColor() { var color = Hex1bColor.FromRgb(140, 170, 205); var provider = new InfoLabelDecorationProvider(color); var doc = new Hex1bDocument(" Side: current"); - var span = Assert.Single(provider.GetDecorations(1, 1, doc)); + var span = Assert.ContainsSingle(provider.GetDecorations(1, 1, doc)); - Assert.NotNull(span.Decoration.Foreground); + Assert.IsNotNull(span.Decoration.Foreground); AssertColorEquals(color, span.Decoration.Foreground.Value); } /// /// Verifies info label ignores content lines with colons. /// - [Fact] + [TestMethod] public void InfoLabel_IgnoresContentLinesWithColons() { var provider = new InfoLabelDecorationProvider(); @@ -60,14 +61,14 @@ public void InfoLabel_IgnoresContentLinesWithColons() // Line 1 " Length:" is a real label // Line 3 " int:" also matches the pattern (letters + colon within 25 chars) // InfoLabelDecorationProvider can't distinguish — that's why StringsDetailDecorationProvider exists - Assert.True(spans.Count >= 1); - Assert.Equal(1, spans[0].Start.Line); + Assert.IsGreaterThanOrEqualTo(1, spans.Count); + Assert.AreEqual(1, spans[0].Start.Line); } /// /// Verifies info label ignores colon beyond position25. /// - [Fact] + [TestMethod] public void InfoLabel_IgnoresColonBeyondPosition25() { var provider = new InfoLabelDecorationProvider(); @@ -75,13 +76,13 @@ public void InfoLabel_IgnoresColonBeyondPosition25() var spans = provider.GetDecorations(1, 1, doc); - Assert.Empty(spans); + Assert.IsEmpty(spans); } /// /// Verifies info label ignores empty lines. /// - [Fact] + [TestMethod] public void InfoLabel_IgnoresEmptyLines() { var provider = new InfoLabelDecorationProvider(); @@ -90,13 +91,13 @@ public void InfoLabel_IgnoresEmptyLines() var spans = provider.GetDecorations(1, 3, doc); // Lines 1 and 3 have labels, line 2 is empty - Assert.Equal(2, spans.Count); + Assert.HasCount(2, spans); } /// /// Verifies info label colors hyphenated labels. /// - [Fact] + [TestMethod] public void InfoLabel_ColorsHyphenatedLabels() { var provider = new InfoLabelDecorationProvider(); @@ -104,15 +105,15 @@ public void InfoLabel_ColorsHyphenatedLabels() var spans = provider.GetDecorations(1, 2, doc); - Assert.Equal(2, spans.Count); - Assert.Equal(1, spans[0].Start.Line); - Assert.Equal(2, spans[1].Start.Line); + Assert.HasCount(2, spans); + Assert.AreEqual(1, spans[0].Start.Line); + Assert.AreEqual(2, spans[1].Start.Line); } /// /// Verifies strings detail only colors first line. /// - [Fact] + [TestMethod] public void StringsDetail_OnlyColorsFirstLine() { var provider = new StringsDetailDecorationProvider(); @@ -120,14 +121,14 @@ public void StringsDetail_OnlyColorsFirstLine() var spans = provider.GetDecorations(1, 4, doc); - Assert.Single(spans); - Assert.Equal(1, spans[0].Start.Line); + Assert.ContainsSingle(spans); + Assert.AreEqual(1, spans[0].Start.Line); } /// /// Verifies strings detail ignores when first line has no colon. /// - [Fact] + [TestMethod] public void StringsDetail_IgnoresWhenFirstLineHasNoColon() { var provider = new StringsDetailDecorationProvider(); @@ -135,13 +136,13 @@ public void StringsDetail_IgnoresWhenFirstLineHasNoColon() var spans = provider.GetDecorations(1, 2, doc); - Assert.Empty(spans); + Assert.IsEmpty(spans); } /// /// Verifies strings detail ignores when view starts beyond line1. /// - [Fact] + [TestMethod] public void StringsDetail_IgnoresWhenViewStartsBeyondLine1() { var provider = new StringsDetailDecorationProvider(); @@ -149,13 +150,13 @@ public void StringsDetail_IgnoresWhenViewStartsBeyondLine1() var spans = provider.GetDecorations(2, 3, doc); - Assert.Empty(spans); + Assert.IsEmpty(spans); } /// /// Verifies info label colors jitted methods label. /// - [Fact] + [TestMethod] public void InfoLabel_ColorsJittedMethodsLabel() { var provider = new InfoLabelDecorationProvider(); @@ -163,13 +164,13 @@ public void InfoLabel_ColorsJittedMethodsLabel() var spans = provider.GetDecorations(1, 1, doc); - Assert.Single(spans); + Assert.ContainsSingle(spans); } /// /// Verifies info label ignores colons inside values. /// - [Fact] + [TestMethod] public void InfoLabel_IgnoresColonsInsideValues() { var provider = new InfoLabelDecorationProvider(); @@ -178,13 +179,13 @@ public void InfoLabel_IgnoresColonsInsideValues() var spans = provider.GetDecorations(1, 1, doc); // Only "Description:" should be colored, not "https:" - Assert.Single(spans); + Assert.ContainsSingle(spans); } /// /// Verifies info label ignores trailing colon in value. /// - [Fact] + [TestMethod] public void InfoLabel_IgnoresTrailingColonInValue() { var provider = new InfoLabelDecorationProvider(); @@ -192,13 +193,13 @@ public void InfoLabel_IgnoresTrailingColonInValue() var spans = provider.GetDecorations(1, 1, doc); - Assert.Single(spans); + Assert.ContainsSingle(spans); } /// /// Verifies info label ignores colon after double space in value. /// - [Fact] + [TestMethod] public void InfoLabel_IgnoresColonAfterDoubleSpaceInValue() { var provider = new InfoLabelDecorationProvider(); @@ -206,13 +207,13 @@ public void InfoLabel_IgnoresColonAfterDoubleSpaceInValue() var spans = provider.GetDecorations(1, 1, doc); - Assert.Single(spans); + Assert.ContainsSingle(spans); } /// /// Verifies info label ignores double colon in values. /// - [Fact] + [TestMethod] public void InfoLabel_IgnoresDoubleColonInValues() { var provider = new InfoLabelDecorationProvider(); @@ -220,13 +221,13 @@ public void InfoLabel_IgnoresDoubleColonInValues() var spans = provider.GetDecorations(1, 1, doc); - Assert.Single(spans); + Assert.ContainsSingle(spans); } /// /// Verifies info label colors multiple labels per line. /// - [Fact] + [TestMethod] public void InfoLabel_ColorsMultipleLabelsPerLine() { var provider = new InfoLabelDecorationProvider(); @@ -234,13 +235,13 @@ public void InfoLabel_ColorsMultipleLabelsPerLine() var spans = provider.GetDecorations(1, 1, doc); - Assert.Equal(3, spans.Count); + Assert.HasCount(3, spans); } /// /// Verifies info label colors threading labels. /// - [Fact] + [TestMethod] public void InfoLabel_ColorsThreadingLabels() { var provider = new InfoLabelDecorationProvider(); @@ -248,13 +249,13 @@ public void InfoLabel_ColorsThreadingLabels() var spans = provider.GetDecorations(1, 1, doc); - Assert.Equal(4, spans.Count); + Assert.HasCount(4, spans); } private static void AssertColorEquals(Hex1bColor expected, Hex1bColor actual) { - Assert.Equal(expected.R, actual.R); - Assert.Equal(expected.G, actual.G); - Assert.Equal(expected.B, actual.B); + Assert.AreEqual(expected.R, actual.R); + Assert.AreEqual(expected.G, actual.G); + Assert.AreEqual(expected.B, actual.B); } } diff --git a/tests/Dotsider.Tests/InfoEditorViewRendererTests.cs b/tests/Dotsider.Tests/InfoEditorViewRendererTests.cs index 61b9b268..7b8c5990 100644 --- a/tests/Dotsider.Tests/InfoEditorViewRendererTests.cs +++ b/tests/Dotsider.Tests/InfoEditorViewRendererTests.cs @@ -10,6 +10,7 @@ namespace Dotsider.Tests; /// Tests that blanks filler rows /// instead of showing vim-style ~ markers. /// +[TestClass] public class InfoEditorViewRendererTests : IDisposable { private Hex1bAppWorkloadAdapter? _workload; @@ -19,7 +20,8 @@ public class InfoEditorViewRendererTests : IDisposable /// /// Verifies info renderer never shows tilde in small document. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task InfoRenderer_NeverShowsTilde_InSmallDocument() { _workload = new Hex1bAppWorkloadAdapter(); @@ -49,7 +51,7 @@ public async Task InfoRenderer_NeverShowsTilde_InSmallDocument() EnableInputCoalescing = false }); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = _hex1bApp.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -82,7 +84,7 @@ public async Task InfoRenderer_NeverShowsTilde_InSmallDocument() .Build() .ApplyAsync(_terminal, cts.Token); - Assert.False(foundTilde, "InfoEditorViewRenderer should not show ~ filler lines"); + Assert.IsFalse(foundTilde, "InfoEditorViewRenderer should not show ~ filler lines"); cts.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -93,9 +95,9 @@ public async Task InfoRenderer_NeverShowsTilde_InSmallDocument() /// public void Dispose() { + GC.SuppressFinalize(this); _hex1bApp?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/MachOSymbolReaderTests.cs b/tests/Dotsider.Tests/MachOSymbolReaderTests.cs index c82f3825..3af01174 100644 --- a/tests/Dotsider.Tests/MachOSymbolReaderTests.cs +++ b/tests/Dotsider.Tests/MachOSymbolReaderTests.cs @@ -8,6 +8,7 @@ namespace Dotsider.Tests; /// stab sizes, per-section clamping, LC_FUNCTION_STARTS deltas against the __TEXT /// base, UUIDs, and fat archives — driven with synthetic images on every platform. /// +[TestClass] public class MachOSymbolReaderTests { private const uint ExecFlags = 0x8000_0400; // pure + some instructions @@ -15,6 +16,7 @@ public class MachOSymbolReaderTests private const byte FunStab = 0x24; // N_FUN private static readonly IlcNameDemangler EmptyDemangler = new([]); + private static readonly int[] ExpectedSectionOrdinals = [1, 2, 3]; private static byte[] Image( (string Name, byte Type, byte Ordinal, ulong Value)[] symbols, @@ -39,7 +41,8 @@ private static byte[] Image( /// data section is kept with the leading underscore stripped, and an unrecognized data- /// section label is dropped. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadSymbols_SplitsFunctionsAndRecognizedData() { var image = Image( @@ -53,32 +56,33 @@ public void ReadSymbols_SplitsFunctionsAndRecognizedData() var symbols = MachOSymbolReader.ReadSymbols(image, EmptyDemangler); - Assert.Equal(4, symbols.Count); // three functions + one recognized data node + Assert.HasCount(4, symbols); // three functions + one recognized data node - var main = Assert.Single(symbols, s => s.Name == "frost_main"); - Assert.Equal(0x40, main.Size); // next start - Assert.Equal("__text", main.Section); - Assert.False(main.IsData); - Assert.NotNull(main.FileOffset); + var main = Assert.ContainsSingle(s => s.Name == "frost_main", symbols); + Assert.AreEqual(0x40, main.Size); // next start + Assert.AreEqual("__text", main.Section); + Assert.IsFalse(main.IsData); + Assert.IsNotNull(main.FileOffset); - var helper = Assert.Single(symbols, s => s.Name == "helper"); - Assert.Equal(0xB0, helper.Size); // clamped to __text's end, not __managedcode's start + var helper = Assert.ContainsSingle(s => s.Name == "helper", symbols); + Assert.AreEqual(0xB0, helper.Size); // clamped to __text's end, not __managedcode's start - var managed = Assert.Single(symbols, s => s.Name == "managed_fn"); - Assert.Equal("__managedcode", managed.Section); - Assert.Equal(0x70, managed.Size); // clamped to its own section's end + var managed = Assert.ContainsSingle(s => s.Name == "managed_fn", symbols); + Assert.AreEqual("__managedcode", managed.Section); + Assert.AreEqual(0x70, managed.Size); // clamped to its own section's end - var data = Assert.Single(symbols, s => s.Name == "_ZTV6Widget"); - Assert.True(data.IsData); - Assert.Equal("__const", data.Section); - Assert.DoesNotContain(symbols, s => s.Name.Contains("randomLabel")); + var data = Assert.ContainsSingle(s => s.Name == "_ZTV6Widget", symbols); + Assert.IsTrue(data.IsData); + Assert.AreEqual("__const", data.Section); + Assert.DoesNotContain(s => s.Name.Contains("randomLabel"), symbols); } /// /// Verifies N_FUN stab pairs: the end entry's n_value is the explicit size, /// preferred over nearest-next sizing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadSymbols_NFunPairs_CarryExplicitSizes() { var image = Image( @@ -90,8 +94,8 @@ public void ReadSymbols_NFunPairs_CarryExplicitSizes() var symbols = MachOSymbolReader.ReadSymbols(image, EmptyDemangler); - var stabbed = Assert.Single(symbols, s => s.Name == "dsym_fn"); - Assert.Equal(0x18, stabbed.Size); + var stabbed = Assert.ContainsSingle(s => s.Name == "dsym_fn", symbols); + Assert.AreEqual(0x18, stabbed.Size); } /// @@ -99,7 +103,8 @@ public void ReadSymbols_NFunPairs_CarryExplicitSizes() /// address even when another segment comes first, sizes come from successive starts, the /// last range clamps to its executable section's end, and a zero delta terminates. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadFunctionStartBoundaries_RelativeToTextSegment() { var starts = new DwarfBlob().ULeb(0x1010).ULeb(0x40).ULeb(0).ULeb(0x99).ToArray(); @@ -112,17 +117,18 @@ public void ReadFunctionStartBoundaries_RelativeToTextSegment() var boundaries = MachOSymbolReader.ReadFunctionStartBoundaries(image); - Assert.Equal(2, boundaries.Count); // the zero delta ends the stream - Assert.Equal(0x1_0000_1010UL, boundaries[0].VirtualAddress); - Assert.Equal(0x40, boundaries[0].Size); - Assert.True(boundaries[0].IsBoundary); - Assert.Equal(0x1_0000_1050UL, boundaries[1].VirtualAddress); - Assert.Equal(0xB0, boundaries[1].Size); // clamped to __text's end - Assert.All(boundaries, b => Assert.StartsWith("sub_", b.Name)); + Assert.HasCount(2, boundaries); // the zero delta ends the stream + Assert.AreEqual(0x1_0000_1010UL, boundaries[0].VirtualAddress); + Assert.AreEqual(0x40, boundaries[0].Size); + Assert.IsTrue(boundaries[0].IsBoundary); + Assert.AreEqual(0x1_0000_1050UL, boundaries[1].VirtualAddress); + Assert.AreEqual(0xB0, boundaries[1].Size); // clamped to __text's end + TestAssert.All(boundaries, b => Assert.StartsWith("sub_", b.Name)); } /// Verifies the UUID load command round-trips and its absence reports false. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TryReadUuid_RoundTripsAndReportsAbsence() { byte[] id = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]; @@ -130,19 +136,20 @@ public void TryReadUuid_RoundTripsAndReportsAbsence() [("__TEXT", 0x1_0000_0000, new[] { ("__text", 0x1_0000_1000UL, ExecFlags, new byte[8]) })], uuid: id); - Assert.True(MachOImageReader.TryReadUuid(image, out var uuid)); - Assert.Equal(id, uuid); + Assert.IsTrue(MachOImageReader.TryReadUuid(image, out var uuid)); + Assert.AreSequenceEqual(id, uuid); var plain = SyntheticImageBuilders.BuildMachO( [("__TEXT", 0x1_0000_0000, new[] { ("__text", 0x1_0000_1000UL, ExecFlags, new byte[8]) })]); - Assert.False(MachOImageReader.TryReadUuid(plain, out _)); + Assert.IsFalse(MachOImageReader.TryReadUuid(plain, out _)); } /// /// Verifies fat archives enumerate their slices — big-endian headers, per-arch offsets and /// sizes — and each slice parses as a thin image. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadFatSlices_EnumeratesAndSlicesParse() { var arm = SyntheticImageBuilders.BuildMachO( @@ -152,27 +159,28 @@ public void ReadFatSlices_EnumeratesAndSlicesParse() cpuType: 0x0100_0007); var fat = SyntheticImageBuilders.BuildFat(arm, x64); - Assert.True(MachOImageReader.IsFat(fat)); - Assert.False(MachOImageReader.IsMachO(fat)); + Assert.IsTrue(MachOImageReader.IsFat(fat)); + Assert.IsFalse(MachOImageReader.IsMachO(fat)); var slices = MachOImageReader.ReadFatSlices(fat); - Assert.Equal(2, slices.Count); - Assert.Equal(0x0100000CU, slices[0].CpuType); - Assert.Equal(0x01000007U, slices[1].CpuType); + Assert.HasCount(2, slices); + Assert.AreEqual(0x0100000CU, slices[0].CpuType); + Assert.AreEqual(0x01000007U, slices[1].CpuType); var slice = fat.AsSpan((int)slices[1].Offset, (int)slices[1].Size); - Assert.True(MachOImageReader.IsMachO(slice)); + Assert.IsTrue(MachOImageReader.IsMachO(slice)); var sections = MachOImageReader.ReadSectionList(slice); - Assert.Equal("__text", Assert.Single(sections).Name); + Assert.AreEqual("__text", Assert.ContainsSingle(sections).Name); - Assert.Empty(MachOImageReader.ReadFatSlices(arm)); // thin image: no slices + Assert.IsEmpty(MachOImageReader.ReadFatSlices(arm)); // thin image: no slices } /// /// Verifies section ordinals run across segments in load order and executable flags decide /// the code filter, not names. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadSectionList_OrdinalsSpanSegments() { var image = SyntheticImageBuilders.BuildMachO( @@ -187,23 +195,24 @@ public void ReadSectionList_OrdinalsSpanSegments() var sections = MachOImageReader.ReadSectionList(image); - Assert.Equal(3, sections.Count); - Assert.Equal([1, 2, 3], sections.Select(s => s.Ordinal)); - Assert.True(sections[0].IsExecutable); - Assert.True(sections[1].IsExecutable); - Assert.False(sections[2].IsExecutable); - Assert.Equal("__DATA", sections[2].Segment); + Assert.HasCount(3, sections); + Assert.AreSequenceEqual(ExpectedSectionOrdinals, sections.Select(s => s.Ordinal)); + Assert.IsTrue(sections[0].IsExecutable); + Assert.IsTrue(sections[1].IsExecutable); + Assert.IsFalse(sections[2].IsExecutable); + Assert.AreEqual("__DATA", sections[2].Segment); } /// Verifies malformed inputs yield empty results rather than throwing. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadSymbols_Malformed_ReturnsEmpty() { - Assert.Empty(MachOSymbolReader.ReadSymbols([0xDE, 0xAD], EmptyDemangler)); - Assert.Empty(MachOSymbolReader.ReadFunctionStartBoundaries([0xDE, 0xAD])); + Assert.IsEmpty(MachOSymbolReader.ReadSymbols([0xDE, 0xAD], EmptyDemangler)); + Assert.IsEmpty(MachOSymbolReader.ReadFunctionStartBoundaries([0xDE, 0xAD])); // A symbol pointing at a nonexistent section ordinal is dropped, not misattributed. var orphan = Image([("_ghost", SectType, 99, 0x1_0000_1010)]); - Assert.Empty(MachOSymbolReader.ReadSymbols(orphan, EmptyDemangler)); + Assert.IsEmpty(MachOSymbolReader.ReadSymbols(orphan, EmptyDemangler)); } } diff --git a/tests/Dotsider.Tests/MalformedPeTests.cs b/tests/Dotsider.Tests/MalformedPeTests.cs index 933dad04..161f91db 100644 --- a/tests/Dotsider.Tests/MalformedPeTests.cs +++ b/tests/Dotsider.Tests/MalformedPeTests.cs @@ -6,9 +6,11 @@ namespace Dotsider.Tests; /// /// Tests for Malformed Pe. /// -[Collection("SampleAssemblies")] -public class MalformedPeTests(SampleAssemblyFixture samples) +[TestClass] +public class MalformedPeTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// Generates synthetic malformed PE binaries for fuzzing. /// Each entry is (description, bytes) — the description is used in test output. @@ -253,10 +255,11 @@ static bool TryRvaToOffset(byte[] bytes, int sectionTableOffset, int numberOfSec /// /// Verifies all malformed binaries throw or construct never crash. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AllMalformedBinaries_ThrowOrConstruct_NeverCrash() { - var validPe = File.ReadAllBytes(samples.HelloWorldDll); + var validPe = File.ReadAllBytes(Samples.HelloWorldDll); foreach (var (description, bytes) in GenerateMalformedBinaries(validPe)) { var tempPath = Path.Combine(Path.GetTempPath(), $"dotsider-fuzz-{Guid.NewGuid():N}.dll"); @@ -287,14 +290,15 @@ public void AllMalformedBinaries_ThrowOrConstruct_NeverCrash() /// /// Verifies zero byte file throws bad image format. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ZeroByteFile_ThrowsBadImageFormat() { var tempPath = Path.Combine(Path.GetTempPath(), $"dotsider-fuzz-{Guid.NewGuid():N}.dll"); try { File.WriteAllBytes(tempPath, []); - Assert.ThrowsAny(() => + Assert.Throws(() => { using var _ = new AssemblyAnalyzer(tempPath); }); @@ -308,19 +312,21 @@ public void ZeroByteFile_ThrowsBadImageFormat() /// /// Verifies four byte junk throws bad image format. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FourByteJunk_ThrowsBadImageFormat() { - Assert.ThrowsAny(() => + Assert.Throws(() => { - using var _ = new AssemblyAnalyzer(samples.NonDotNetBinaryPath); + using var _ = new AssemblyAnalyzer(Samples.NonDotNetBinaryPath); }); } /// /// Verifies truncated mz header throws bad image format. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TruncatedMzHeader_ThrowsBadImageFormat() { var tempPath = Path.Combine(Path.GetTempPath(), $"dotsider-fuzz-{Guid.NewGuid():N}.dll"); @@ -328,7 +334,7 @@ public void TruncatedMzHeader_ThrowsBadImageFormat() { // Just the MZ magic bytes with no PE signature pointer File.WriteAllBytes(tempPath, [(byte)'M', (byte)'Z']); - Assert.ThrowsAny(() => + Assert.Throws(() => { using var _ = new AssemblyAnalyzer(tempPath); }); @@ -342,10 +348,11 @@ public void TruncatedMzHeader_ThrowsBadImageFormat() /// /// Verifies valid pe header truncated before metadata throws bad image format. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ValidPeHeader_TruncatedBeforeMetadata_ThrowsBadImageFormat() { - var validPe = File.ReadAllBytes(samples.HelloWorldDll); + var validPe = File.ReadAllBytes(Samples.HelloWorldDll); // Keep DOS header + PE signature but truncate before CLR metadata var truncated = validPe[..Math.Min(256, validPe.Length)]; @@ -353,7 +360,7 @@ public void ValidPeHeader_TruncatedBeforeMetadata_ThrowsBadImageFormat() try { File.WriteAllBytes(tempPath, truncated); - Assert.ThrowsAny(() => + Assert.Throws(() => { using var _ = new AssemblyAnalyzer(tempPath); }); @@ -367,7 +374,8 @@ public void ValidPeHeader_TruncatedBeforeMetadata_ThrowsBadImageFormat() /// /// Verifies push assembly malformed file returns false and preserves state. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PushAssembly_MalformedFile_ReturnsFalseAndPreservesState() { var workload = new Hex1b.Hex1bAppWorkloadAdapter(); @@ -382,19 +390,19 @@ public void PushAssembly_MalformedFile_ReturnsFalseAndPreservesState() try { - using var state = new DotsiderState(app, samples.HelloWorldDll); + using var state = new DotsiderState(app, Samples.HelloWorldDll); var originalFile = state.Analyzer.FileName; // Try to push a truncated PE var tempPath = Path.Combine(Path.GetTempPath(), $"dotsider-fuzz-{Guid.NewGuid():N}.dll"); try { - var validPe = File.ReadAllBytes(samples.HelloWorldDll); + var validPe = File.ReadAllBytes(Samples.HelloWorldDll); File.WriteAllBytes(tempPath, validPe[..128]); - Assert.False(state.PushAssembly(tempPath)); - Assert.Equal(originalFile, state.Analyzer.FileName); - Assert.NotNull(state.NavigationError); + Assert.IsFalse(state.PushAssembly(tempPath)); + Assert.AreEqual(originalFile, state.Analyzer.FileName); + Assert.IsNotNull(state.NavigationError); } finally { diff --git a/tests/Dotsider.Tests/ManagedNativeIndexTests.cs b/tests/Dotsider.Tests/ManagedNativeIndexTests.cs index 56e3bbb9..24915612 100644 --- a/tests/Dotsider.Tests/ManagedNativeIndexTests.cs +++ b/tests/Dotsider.Tests/ManagedNativeIndexTests.cs @@ -8,6 +8,7 @@ namespace Dotsider.Tests; /// Tests for the managed↔native correlation index: join rules against hand-written /// ILC-form symbol names, mstat evidence, shared-pool accounting, and lookups. /// +[TestClass] public class ManagedNativeIndexTests { private static MethodDefInfo Method(string declaringType, string name, int token) => @@ -29,7 +30,8 @@ private static ManagedNativeIndex Build( ManagedNativeIndex.Build(sources, symbols, mstat); /// Verifies a unique method joins its symbol exactly and owns its size. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_UniqueMethodWithSymbol_CorrelatedExact() { var index = Build( @@ -37,17 +39,18 @@ [new ManagedMethodSource("App", [Method("Greeter", "Run", 0x06000001)])], [Symbol("App_Greeter__Run", 0x1000, 64)]); var correlation = index.Find("App", 0x06000001); - Assert.NotNull(correlation); - Assert.Equal(MethodCorrelationStatus.CorrelatedExact, correlation!.Status); - Assert.Single(correlation.NativeSymbols); - Assert.Equal(64, correlation.NativeSize); - Assert.Equal(0, correlation.SharedCandidateSize); - Assert.Equal(1, index.ExactCount); - Assert.Equal(64, index.TotalCorrelatedSize); + Assert.IsNotNull(correlation); + Assert.AreEqual(MethodCorrelationStatus.CorrelatedExact, correlation!.Status); + Assert.ContainsSingle(correlation.NativeSymbols); + Assert.AreEqual(64, correlation.NativeSize); + Assert.AreEqual(0, correlation.SharedCandidateSize); + Assert.AreEqual(1, index.ExactCount); + Assert.AreEqual(64, index.TotalCorrelatedSize); } /// Verifies overloads share their evidence pool: both ambiguous, own nothing, counted once. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_OverloadsWithSuffixedSymbols_SharedAmbiguousPool() { var index = Build( @@ -63,18 +66,19 @@ [new ManagedMethodSource("App", var first = index.Find("App", 0x06000002)!; var second = index.Find("App", 0x06000003)!; - Assert.Equal(MethodCorrelationStatus.CorrelatedAmbiguous, first.Status); - Assert.Equal(MethodCorrelationStatus.CorrelatedAmbiguous, second.Status); - Assert.Equal(2, first.NativeSymbols.Count); - Assert.Equal(0, first.NativeSize); - Assert.Equal(160, first.SharedCandidateSize); - Assert.Equal(160, second.SharedCandidateSize); - Assert.Equal(160, index.TotalCorrelatedSize); // pool counted once, never per candidate - Assert.Equal(2, index.AmbiguousCount); + Assert.AreEqual(MethodCorrelationStatus.CorrelatedAmbiguous, first.Status); + Assert.AreEqual(MethodCorrelationStatus.CorrelatedAmbiguous, second.Status); + Assert.HasCount(2, first.NativeSymbols); + Assert.AreEqual(0, first.NativeSize); + Assert.AreEqual(160, first.SharedCandidateSize); + Assert.AreEqual(160, second.SharedCandidateSize); + Assert.AreEqual(160, index.TotalCorrelatedSize); // pool counted once, never per candidate + Assert.AreEqual(2, index.AmbiguousCount); } /// Verifies generic instantiations accumulate on one method as an exact join. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_GenericInstantiations_MultiSymbolExact() { var index = Build( @@ -85,13 +89,14 @@ [new ManagedMethodSource("App", [Method("Greeter", "Describe", 0x06000004)])], ]); var correlation = index.Find("App", 0x06000004)!; - Assert.Equal(MethodCorrelationStatus.CorrelatedExact, correlation.Status); - Assert.Equal(2, correlation.NativeSymbols.Count); - Assert.Equal(92, correlation.NativeSize); + Assert.AreEqual(MethodCorrelationStatus.CorrelatedExact, correlation.Status); + Assert.HasCount(2, correlation.NativeSymbols); + Assert.AreEqual(92, correlation.NativeSize); } /// Verifies constructor and accessor names join through the shared sanitize rules. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_CtorAndAccessor_Join() { var index = Build( @@ -107,13 +112,14 @@ [new ManagedMethodSource("App", Symbol("App_Greeter___cctor", 0x3000, 16), ]); - Assert.Equal(MethodCorrelationStatus.CorrelatedExact, index.Find("App", 0x06000005)!.Status); - Assert.Equal(MethodCorrelationStatus.CorrelatedExact, index.Find("App", 0x06000006)!.Status); - Assert.Equal(MethodCorrelationStatus.CorrelatedExact, index.Find("App", 0x06000007)!.Status); + Assert.AreEqual(MethodCorrelationStatus.CorrelatedExact, index.Find("App", 0x06000005)!.Status); + Assert.AreEqual(MethodCorrelationStatus.CorrelatedExact, index.Find("App", 0x06000006)!.Status); + Assert.AreEqual(MethodCorrelationStatus.CorrelatedExact, index.Find("App", 0x06000007)!.Status); } /// Verifies a method with no evidence at all is reported as not in the native image. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_NoEvidence_NotInNativeImage() { var index = Build( @@ -121,14 +127,15 @@ [new ManagedMethodSource("App", [Method("Greeter", "NeverCalled", 0x06000008)])] []); var correlation = index.Find("App", 0x06000008)!; - Assert.Equal(MethodCorrelationStatus.NotInNativeImage, correlation.Status); - Assert.Equal(0, correlation.NativeSize); - Assert.Equal(1, index.NotInImageCount); - Assert.Equal(0, index.TotalCorrelatedSize); + Assert.AreEqual(MethodCorrelationStatus.NotInNativeImage, correlation.Status); + Assert.AreEqual(0, correlation.NativeSize); + Assert.AreEqual(1, index.NotInImageCount); + Assert.AreEqual(0, index.TotalCorrelatedSize); } /// Verifies mstat-only evidence yields the size-only status with an owned size. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_MstatOnlyEvidence_CorrelatedByMstatOnly() { var index = Build( @@ -137,15 +144,16 @@ [new ManagedMethodSource("App", [Method("Greeter", "SizeOnly", 0x06000009)])], Mstat(MstatRow("Greeter", "SizeOnly", 123))); var correlation = index.Find("App", 0x06000009)!; - Assert.Equal(MethodCorrelationStatus.CorrelatedByMstatOnly, correlation.Status); - Assert.Empty(correlation.NativeSymbols); - Assert.Equal(123, correlation.NativeSize); - Assert.Equal(1, index.MstatOnlyCount); - Assert.Equal(123, index.TotalCorrelatedSize); + Assert.AreEqual(MethodCorrelationStatus.CorrelatedByMstatOnly, correlation.Status); + Assert.IsEmpty(correlation.NativeSymbols); + Assert.AreEqual(123, correlation.NativeSize); + Assert.AreEqual(1, index.MstatOnlyCount); + Assert.AreEqual(123, index.TotalCorrelatedSize); } /// Verifies shared mstat evidence among overloads is never double-counted. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_SharedMstatEvidence_CountedOnce() { var index = Build( @@ -158,14 +166,15 @@ [new ManagedMethodSource("App", Mstat(MstatRow("Greeter", "Greet", 100), MstatRow("Greeter", "Greet", 100))); var first = index.Find("App", 0x0600000A)!; - Assert.Equal(MethodCorrelationStatus.CorrelatedByMstatOnly, first.Status); - Assert.Equal(0, first.NativeSize); - Assert.Equal(200, first.SharedCandidateSize); - Assert.Equal(200, index.TotalCorrelatedSize); + Assert.AreEqual(MethodCorrelationStatus.CorrelatedByMstatOnly, first.Status); + Assert.AreEqual(0, first.NativeSize); + Assert.AreEqual(200, first.SharedCandidateSize); + Assert.AreEqual(200, index.TotalCorrelatedSize); } /// Verifies mstat sizes are preferred over symbol sizes for the same method, never summed. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_MstatAndSymbolEvidence_MstatSizePreferred() { var index = Build( @@ -174,13 +183,14 @@ [new ManagedMethodSource("App", [Method("Greeter", "Run", 0x0600000C)])], Mstat(MstatRow("Greeter", "Run", 80))); var correlation = index.Find("App", 0x0600000C)!; - Assert.Equal(MethodCorrelationStatus.CorrelatedExact, correlation.Status); - Assert.Equal(80, correlation.NativeSize); - Assert.Equal(80, index.TotalCorrelatedSize); + Assert.AreEqual(MethodCorrelationStatus.CorrelatedExact, correlation.Status); + Assert.AreEqual(80, correlation.NativeSize); + Assert.AreEqual(80, index.TotalCorrelatedSize); } /// Verifies same-token methods in different assemblies stay distinct. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_MultiAssemblySources_TokenCollisionsStayDistinct() { const int token = 0x06000001; @@ -196,14 +206,15 @@ public void Build_MultiAssemblySources_TokenCollisionsStayDistinct() var app = index.Find("App", token)!; var lib = index.Find("Lib", token)!; - Assert.Equal(10, app.NativeSize); - Assert.Equal(20, lib.NativeSize); - Assert.Equal(0x1000UL, app.NativeSymbols[0].VirtualAddress); - Assert.Equal(0x2000UL, lib.NativeSymbols[0].VirtualAddress); + Assert.AreEqual(10, app.NativeSize); + Assert.AreEqual(20, lib.NativeSize); + Assert.AreEqual(0x1000UL, app.NativeSymbols[0].VirtualAddress); + Assert.AreEqual(0x2000UL, lib.NativeSymbols[0].VirtualAddress); } /// Verifies mstat rows only join sources whose assembly name matches. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_MstatAssemblyFilter_ForeignRowsIgnored() { var index = Build( @@ -211,22 +222,24 @@ [new ManagedMethodSource("App", [Method("Greeter", "Run", 0x0600000D)])], [], Mstat(MstatRow("Greeter", "Run", 999, assembly: "Other"))); - Assert.Equal(MethodCorrelationStatus.NotInNativeImage, index.Find("App", 0x0600000D)!.Status); + Assert.AreEqual(MethodCorrelationStatus.NotInNativeImage, index.Find("App", 0x0600000D)!.Status); } /// Verifies mstat names lose only a balanced trailing instantiation group; <Main>$ survives. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void StripTrailingInstantiation_TrailingGroupOnly() { - Assert.Equal("Describe", ManagedNativeIndex.StripTrailingInstantiation("Describe")); - Assert.Equal("List`1", ManagedNativeIndex.StripTrailingInstantiation("List`1")); - Assert.Equal("
$", ManagedNativeIndex.StripTrailingInstantiation("
$")); - Assert.Equal("Plain", ManagedNativeIndex.StripTrailingInstantiation("Plain")); - Assert.Equal("Outer.Inner", ManagedNativeIndex.StripTrailingInstantiation("Outer.Inner")); + Assert.AreEqual("Describe", ManagedNativeIndex.StripTrailingInstantiation("Describe")); + Assert.AreEqual("List`1", ManagedNativeIndex.StripTrailingInstantiation("List`1")); + Assert.AreEqual("
$", ManagedNativeIndex.StripTrailingInstantiation("
$")); + Assert.AreEqual("Plain", ManagedNativeIndex.StripTrailingInstantiation("Plain")); + Assert.AreEqual("Outer.Inner", ManagedNativeIndex.StripTrailingInstantiation("Outer.Inner")); } /// Verifies an instantiated mstat row matches its definition. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_MstatInstantiatedRow_JoinsDefinition() { var index = Build( @@ -235,12 +248,13 @@ [new ManagedMethodSource("App", [Method("Greeter", "Describe", 0x0600000E)])], Mstat(MstatRow("Greeter", "Describe", 42))); var correlation = index.Find("App", 0x0600000E)!; - Assert.Equal(MethodCorrelationStatus.CorrelatedByMstatOnly, correlation.Status); - Assert.Equal(42, correlation.NativeSize); + Assert.AreEqual(MethodCorrelationStatus.CorrelatedByMstatOnly, correlation.Status); + Assert.AreEqual(42, correlation.NativeSize); } /// Verifies reverse lookup by address, including the shared-pool first-candidate convention. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindByAddress_OwnedAndShared_Resolve() { var index = Build( @@ -256,20 +270,21 @@ [new ManagedMethodSource("App", ]); var owned = index.FindByAddress(0x1000); - Assert.NotNull(owned); - Assert.Equal("Run", owned!.Method.Name); - Assert.Equal(MethodCorrelationStatus.CorrelatedExact, owned.Status); + Assert.IsNotNull(owned); + Assert.AreEqual("Run", owned!.Method.Name); + Assert.AreEqual(MethodCorrelationStatus.CorrelatedExact, owned.Status); var shared = index.FindByAddress(0x2000); - Assert.NotNull(shared); - Assert.Equal("Greet", shared!.Method.Name); - Assert.Equal(MethodCorrelationStatus.CorrelatedAmbiguous, shared.Status); + Assert.IsNotNull(shared); + Assert.AreEqual("Greet", shared!.Method.Name); + Assert.AreEqual(MethodCorrelationStatus.CorrelatedAmbiguous, shared.Status); - Assert.Null(index.FindByAddress(0xDEAD)); + Assert.IsNull(index.FindByAddress(0xDEAD)); } /// Verifies namespaced and nested declaring types join through sanitization. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_NamespacedAndNestedTypes_Join() { var index = Build( @@ -283,23 +298,25 @@ [new ManagedMethodSource("App", Symbol("App_MyApp_Outer_Inner__Poke", 0x2000, 12), ]); - Assert.Equal(MethodCorrelationStatus.CorrelatedExact, index.Find("App", 0x06000001)!.Status); - Assert.Equal(MethodCorrelationStatus.CorrelatedExact, index.Find("App", 0x06000002)!.Status); + Assert.AreEqual(MethodCorrelationStatus.CorrelatedExact, index.Find("App", 0x06000001)!.Status); + Assert.AreEqual(MethodCorrelationStatus.CorrelatedExact, index.Find("App", 0x06000002)!.Status); } /// Verifies a Mach-O style leading underscore still joins. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_LeadingUnderscoreSymbol_Joins() { var index = Build( [new ManagedMethodSource("App", [Method("Greeter", "Run", 0x06000001)])], [Symbol("_App_Greeter__Run", 0x1000, 10)]); - Assert.Equal(MethodCorrelationStatus.CorrelatedExact, index.Find("App", 0x06000001)!.Status); + Assert.AreEqual(MethodCorrelationStatus.CorrelatedExact, index.Find("App", 0x06000001)!.Status); } /// Verifies non-function symbols never join as methods. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_NonFunctionSymbols_Ignored() { var index = Build( @@ -309,26 +326,28 @@ [new ManagedMethodSource("App", [Method("Greeter", "Run", 0x06000001)])], NativeSymbolKind.Data, null, null, false, []), ]); - Assert.Equal(MethodCorrelationStatus.NotInNativeImage, index.Find("App", 0x06000001)!.Status); + Assert.AreEqual(MethodCorrelationStatus.NotInNativeImage, index.Find("App", 0x06000001)!.Status); } /// Verifies an address inside a correlated symbol (not just its entry) resolves. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindByAddress_InsideSymbolRange_Resolves() { var index = Build( [new ManagedMethodSource("App", [Method("Greeter", "Run", 0x06000001)])], [Symbol("App_Greeter__Run", 0x1000, 0x40)]); - Assert.Equal("Run", index.FindByAddress(0x1000)!.Method.Name); // entry point - Assert.Equal("Run", index.FindByAddress(0x1020)!.Method.Name); // inside the body - Assert.Equal("Run", index.FindByAddress(0x103F)!.Method.Name); // last byte - Assert.Null(index.FindByAddress(0x1040)); // one past the end (exclusive) - Assert.Null(index.FindByAddress(0x0FFF)); // before the start + Assert.AreEqual("Run", index.FindByAddress(0x1000)!.Method.Name); // entry point + Assert.AreEqual("Run", index.FindByAddress(0x1020)!.Method.Name); // inside the body + Assert.AreEqual("Run", index.FindByAddress(0x103F)!.Method.Name); // last byte + Assert.IsNull(index.FindByAddress(0x1040)); // one past the end (exclusive) + Assert.IsNull(index.FindByAddress(0x0FFF)); // before the start } /// Verifies alias symbols sharing a virtual address contribute their size once. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_AliasSymbolsAtSameVa_SizeCountedOnce() { var index = Build( @@ -339,13 +358,14 @@ [new ManagedMethodSource("App", [Method("Greeter", "Run", 0x06000001)])], ]); var run = index.Find("App", 0x06000001)!; - Assert.Equal(MethodCorrelationStatus.CorrelatedExact, run.Status); - Assert.Equal(16, run.NativeSize); - Assert.Equal(16, index.TotalCorrelatedSize); + Assert.AreEqual(MethodCorrelationStatus.CorrelatedExact, run.Status); + Assert.AreEqual(16, run.NativeSize); + Assert.AreEqual(16, index.TotalCorrelatedSize); } /// Verifies mstat rows repeating a dependency-graph node count their size once. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_RepeatedMstatNode_SizeCountedOnce() { var index = Build( @@ -356,13 +376,14 @@ [new ManagedMethodSource("App", [Method("Greeter", "Run", 0x06000001)])], new MstatMethod("Run", "Greeter", "", "App", 30, 0, 0, "Greeter.Run()"))); // same node var run = index.Find("App", 0x06000001)!; - Assert.Equal(MethodCorrelationStatus.CorrelatedByMstatOnly, run.Status); - Assert.Equal(30, run.NativeSize); - Assert.Equal(30, index.TotalCorrelatedSize); + Assert.AreEqual(MethodCorrelationStatus.CorrelatedByMstatOnly, run.Status); + Assert.AreEqual(30, run.NativeSize); + Assert.AreEqual(30, index.TotalCorrelatedSize); } /// Verifies distinct mstat nodes (generic instantiations) each count their size. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_DistinctMstatNodes_SizesSum() { var index = Build( @@ -372,7 +393,7 @@ [new ManagedMethodSource("App", [Method("Greeter", "Describe", 0x06000001)])], new MstatMethod("Describe", "Greeter", "", "App", 30, 0, 0, "Greeter.Describe()"), new MstatMethod("Describe", "Greeter", "", "App", 40, 0, 0, "Greeter.Describe()"))); - Assert.Equal(70, index.Find("App", 0x06000001)!.NativeSize); - Assert.Equal(70, index.TotalCorrelatedSize); + Assert.AreEqual(70, index.Find("App", 0x06000001)!.NativeSize); + Assert.AreEqual(70, index.TotalCorrelatedSize); } } diff --git a/tests/Dotsider.Tests/MatchNavigationTests.cs b/tests/Dotsider.Tests/MatchNavigationTests.cs index 064124d6..5ef863e4 100644 --- a/tests/Dotsider.Tests/MatchNavigationTests.cs +++ b/tests/Dotsider.Tests/MatchNavigationTests.cs @@ -4,28 +4,31 @@ namespace Dotsider.Tests; /// Tests for match navigation: next/prev through matches, wrap-around, /// single match, zero matches, backwards navigation. /// +[TestClass] public class MatchNavigationTests { /// /// Verifies navigate next wraps around. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigateNext_WrapsAround() { var index = 0; var count = 3; // Next 4 times should wrap - index = (index + 1) % count; Assert.Equal(1, index); - index = (index + 1) % count; Assert.Equal(2, index); - index = (index + 1) % count; Assert.Equal(0, index); // wrapped - index = (index + 1) % count; Assert.Equal(1, index); + index = (index + 1) % count; Assert.AreEqual(1, index); + index = (index + 1) % count; Assert.AreEqual(2, index); + index = (index + 1) % count; Assert.AreEqual(0, index); // wrapped + index = (index + 1) % count; Assert.AreEqual(1, index); } /// /// Verifies navigate prev wraps around. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigatePrev_WrapsAround() { var index = 0; @@ -33,31 +36,33 @@ public void NavigatePrev_WrapsAround() // Prev from 0 should go to last index = index <= 0 ? count - 1 : index - 1; - Assert.Equal(2, index); // wrapped to last + Assert.AreEqual(2, index); // wrapped to last index = index <= 0 ? count - 1 : index - 1; - Assert.Equal(1, index); + Assert.AreEqual(1, index); index = index <= 0 ? count - 1 : index - 1; - Assert.Equal(0, index); + Assert.AreEqual(0, index); } /// /// Verifies single match stays on same. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SingleMatch_StaysOnSame() { var index = 0; var count = 1; index = (index + 1) % count; - Assert.Equal(0, index); + Assert.AreEqual(0, index); index = index <= 0 ? count - 1 : index - 1; - Assert.Equal(0, index); + Assert.AreEqual(0, index); } /// /// Verifies hex match navigation next wraps. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HexMatchNavigation_NextWraps() { var offsets = new List { 0x100, 0x200, 0x300 }; @@ -65,29 +70,30 @@ public void HexMatchNavigation_NextWraps() // First next currentIndex = (currentIndex + 1) % offsets.Count; - Assert.Equal(0, currentIndex); - Assert.Equal(0x100, offsets[currentIndex]); + Assert.AreEqual(0, currentIndex); + Assert.AreEqual(0x100, offsets[currentIndex]); // Second next currentIndex = (currentIndex + 1) % offsets.Count; - Assert.Equal(1, currentIndex); - Assert.Equal(0x200, offsets[currentIndex]); + Assert.AreEqual(1, currentIndex); + Assert.AreEqual(0x200, offsets[currentIndex]); // Third next currentIndex = (currentIndex + 1) % offsets.Count; - Assert.Equal(2, currentIndex); - Assert.Equal(0x300, offsets[currentIndex]); + Assert.AreEqual(2, currentIndex); + Assert.AreEqual(0x300, offsets[currentIndex]); // Fourth next (wraps) currentIndex = (currentIndex + 1) % offsets.Count; - Assert.Equal(0, currentIndex); - Assert.Equal(0x100, offsets[currentIndex]); + Assert.AreEqual(0, currentIndex); + Assert.AreEqual(0x100, offsets[currentIndex]); } /// /// Verifies hex match navigation prev wraps. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HexMatchNavigation_PrevWraps() { var offsets = new List { 0x100, 0x200, 0x300 }; @@ -95,41 +101,43 @@ public void HexMatchNavigation_PrevWraps() // Prev from 0 wraps to last currentIndex = currentIndex <= 0 ? offsets.Count - 1 : currentIndex - 1; - Assert.Equal(2, currentIndex); - Assert.Equal(0x300, offsets[currentIndex]); + Assert.AreEqual(2, currentIndex); + Assert.AreEqual(0x300, offsets[currentIndex]); } /// /// Verifies zero matches navigate is noop. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ZeroMatches_NavigateIsNoop() { var offsets = new List(); // With zero matches, navigation delegates should be null or no-op // The spec says n/N with zero matches: no-op - Assert.Empty(offsets); + Assert.IsEmpty(offsets); } /// /// Verifies backwards navigation from middle. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void BackwardsNavigation_FromMiddle() { var offsets = new List { 10, 20, 30, 40, 50 }; var index = 2; // start at 30 index = index <= 0 ? offsets.Count - 1 : index - 1; - Assert.Equal(1, index); - Assert.Equal(20, offsets[index]); + Assert.AreEqual(1, index); + Assert.AreEqual(20, offsets[index]); index = index <= 0 ? offsets.Count - 1 : index - 1; - Assert.Equal(0, index); - Assert.Equal(10, offsets[index]); + Assert.AreEqual(0, index); + Assert.AreEqual(10, offsets[index]); index = index <= 0 ? offsets.Count - 1 : index - 1; - Assert.Equal(4, index); // wrapped - Assert.Equal(50, offsets[index]); + Assert.AreEqual(4, index); // wrapped + Assert.AreEqual(50, offsets[index]); } } diff --git a/tests/Dotsider.Tests/MsfFileTests.cs b/tests/Dotsider.Tests/MsfFileTests.cs index 9732d07e..4851f50f 100644 --- a/tests/Dotsider.Tests/MsfFileTests.cs +++ b/tests/Dotsider.Tests/MsfFileTests.cs @@ -6,13 +6,15 @@ namespace Dotsider.Tests; /// Tests for , the MSF 7.0 container reader, using synthetic images so the /// block and directory math is exercised on every platform. /// +[TestClass] public class MsfFileTests { /// /// Verifies a stream's bytes round-trip through the block directory, including a stream whose /// content spans more than one block. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetStream_MultiBlockStream_RoundTrips() { var payload = new byte[1500]; @@ -21,15 +23,16 @@ public void GetStream_MultiBlockStream_RoundTrips() var msf = MsfFile.TryOpen(image); - Assert.NotNull(msf); - Assert.Equal(payload, msf.GetStream(1)); + Assert.IsNotNull(msf); + Assert.AreSequenceEqual(payload, msf.GetStream(1)); } /// /// Verifies a multi-block stream directory (more directory bytes than one block holds) is /// concatenated and read correctly. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TryOpen_MultiBlockDirectory_ResolvesAllStreams() { // Many small streams push the directory past one 512-byte block. @@ -39,40 +42,43 @@ public void TryOpen_MultiBlockDirectory_ResolvesAllStreams() var msf = MsfFile.TryOpen(image); - Assert.NotNull(msf); - Assert.Equal(61, msf.StreamCount); // stream 0 + 60 - Assert.Equal([59, 60], msf.GetStream(60)); + Assert.IsNotNull(msf); + Assert.AreEqual(61, msf.StreamCount); // stream 0 + 60 + Assert.AreSequenceEqual(";<"u8.ToArray(), msf.GetStream(60)); } /// /// Verifies a nil stream (size 0xFFFFFFFF) reports zero length and yields no bytes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetStream_NilStream_IsEmpty() { var image = SyntheticImageBuilders.BuildMsf(512, [1, 2, 3], null); var msf = MsfFile.TryOpen(image); - Assert.NotNull(msf); - Assert.Equal(0, msf.StreamSize(2)); - Assert.Empty(msf.GetStream(2)); + Assert.IsNotNull(msf); + Assert.AreEqual(0, msf.StreamSize(2)); + Assert.IsEmpty(msf.GetStream(2)); } /// /// Verifies non-MSF bytes return null rather than throwing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TryOpen_NotMsf_ReturnsNull() { - Assert.Null(MsfFile.TryOpen([0xDE, 0xAD, 0xBE, 0xEF])); - Assert.Null(MsfFile.TryOpen(new byte[512])); + Assert.IsNull(MsfFile.TryOpen([0xDE, 0xAD, 0xBE, 0xEF])); + Assert.IsNull(MsfFile.TryOpen(new byte[512])); } /// /// Verifies an out-of-range block index in the directory is rejected as null. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TryOpen_CorruptBlockIndex_ReturnsNull() { var image = SyntheticImageBuilders.BuildMsf(512, [1, 2, 3, 4]); @@ -82,6 +88,6 @@ public void TryOpen_CorruptBlockIndex_ReturnsNull() image[blockMapOffset] = 0xFF; image[blockMapOffset + 1] = 0xFF; - Assert.Null(MsfFile.TryOpen(image)); + Assert.IsNull(MsfFile.TryOpen(image)); } } diff --git a/tests/Dotsider.Tests/MstatDifferTests.cs b/tests/Dotsider.Tests/MstatDifferTests.cs index ac383955..0de0198d 100644 --- a/tests/Dotsider.Tests/MstatDifferTests.cs +++ b/tests/Dotsider.Tests/MstatDifferTests.cs @@ -10,17 +10,19 @@ namespace Dotsider.Tests; /// platform and toolchain, so every size assertion is sign/kind/order-based, never exact /// bytes. /// -[Collection("SampleAssemblies")] -public class MstatDifferTests(SampleAssemblyFixture samples) +[TestClass] +public class MstatDifferTests { - private (MstatData V1, MstatData V2) ReadPair() + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + + private static (MstatData V1, MstatData V2) ReadPair() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "V1 mstat sidecar was not produced"); - Assert.SkipWhen(samples.NativeAotConsoleV2Mstat is null, "V2 mstat sidecar was not produced"); - var v1 = MstatReader.Read(samples.NativeAotConsoleMstat!); - var v2 = MstatReader.Read(samples.NativeAotConsoleV2Mstat!); - Assert.NotNull(v1); - Assert.NotNull(v2); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "V1 mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleV2Mstat is null, "V2 mstat sidecar was not produced"); + var v1 = MstatReader.Read(Samples.NativeAotConsoleMstat!); + var v2 = MstatReader.Read(Samples.NativeAotConsoleV2Mstat!); + Assert.IsNotNull(v1); + Assert.IsNotNull(v2); return (v1, v2); } @@ -28,55 +30,58 @@ public class MstatDifferTests(SampleAssemblyFixture samples) /// Verifies the namespace that exists only in V2 diffs as a fully added subtree under the /// app's assembly, with zero baseline bytes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Compare_V1V2_AddedNamespaceDetected() { var (v1, v2) = ReadPair(); var diff = MstatDiffer.Compare(v1, v2); - var assembly = Assert.Single(diff.Root.Children, n => n.Name == "NativeAotConsole"); - var telemetry = Assert.Single(assembly.Children, n => n.Name == "NativeAotConsole.Telemetry"); - Assert.Equal(DiffKind.Added, telemetry.Diff); - Assert.Equal(0, telemetry.LeftSize); - Assert.True(telemetry.Delta > 0); - Assert.Equal(telemetry.RightSize, telemetry.Delta); + var assembly = Assert.ContainsSingle(n => n.Name == "NativeAotConsole", diff.Root.Children); + var telemetry = Assert.ContainsSingle(n => n.Name == "NativeAotConsole.Telemetry", assembly.Children); + Assert.AreEqual(DiffKind.Added, telemetry.Diff); + Assert.AreEqual(0, telemetry.LeftSize); + Assert.IsGreaterThan(0, telemetry.Delta); + Assert.AreEqual(telemetry.RightSize, telemetry.Delta); } /// /// Verifies the property accessor removed in V2 diffs as a removed method with a negative /// delta. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Compare_V1V2_RemovedMethodDetected() { var (v1, v2) = ReadPair(); var diff = MstatDiffer.Compare(v1, v2); - var removed = Assert.Single(diff.Contributors, c => - c.Name == "get_Name()" && c.AssemblyName == "NativeAotConsole"); - Assert.Equal(DiffKind.Removed, removed.Diff); - Assert.True(removed.Delta < 0); - Assert.Equal(0, removed.RightSize); + var removed = Assert.ContainsSingle(c => + c.Name == "get_Name()" && c.AssemblyName == "NativeAotConsole", diff.Contributors); + Assert.AreEqual(DiffKind.Removed, removed.Diff); + Assert.IsLessThan(0, removed.Delta); + Assert.AreEqual(0, removed.RightSize); } /// /// Verifies the overload grown in V2 diffs as changed with a positive delta — sign only, /// never exact bytes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Compare_V1V2_GrownMethodDetected() { var (v1, v2) = ReadPair(); var diff = MstatDiffer.Compare(v1, v2); - var grown = Assert.Single(diff.Contributors, c => - c.Name == "Greet(string)" && c.AssemblyName == "NativeAotConsole"); - Assert.Equal(DiffKind.Changed, grown.Diff); - Assert.True(grown.Delta > 0); - Assert.True(grown.LeftSize > 0); + var grown = Assert.ContainsSingle(c => + c.Name == "Greet(string)" && c.AssemblyName == "NativeAotConsole", diff.Contributors); + Assert.AreEqual(DiffKind.Changed, grown.Diff); + Assert.IsGreaterThan(0, grown.Delta); + Assert.IsGreaterThan(0, grown.LeftSize); } /// @@ -84,43 +89,46 @@ public void Compare_V1V2_GrownMethodDetected() /// untouched, so the string overload appears among the changed entries and the int /// overload does not — the two are never merged into one row. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Compare_V1V2_OverloadsTrackedSeparately() { var (v1, v2) = ReadPair(); var diff = MstatDiffer.Compare(v1, v2); - Assert.Contains(diff.Contributors, c => - c.Name == "Greet(string)" && c.AssemblyName == "NativeAotConsole"); - Assert.DoesNotContain(diff.Contributors, c => - c.Name == "Greet(int)" && c.AssemblyName == "NativeAotConsole"); + Assert.Contains(c => + c.Name == "Greet(string)" && c.AssemblyName == "NativeAotConsole", diff.Contributors); + Assert.DoesNotContain(c => + c.Name == "Greet(int)" && c.AssemblyName == "NativeAotConsole", diff.Contributors); } /// /// Verifies the manifest resource embedded only in V2 produces an added Resources /// category. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Compare_V1V2_ResourceCategoryAdded() { var (v1, v2) = ReadPair(); - Assert.SkipWhen(v2.ManifestResources.Count == 0, "V2 fixture embeds no resources"); + TestSkip.When(v2.ManifestResources.Count == 0, "V2 fixture embeds no resources"); var diff = MstatDiffer.Compare(v1, v2); - var resources = Assert.Single(diff.Root.Children, n => n.Name == "Resources"); - Assert.Equal(DiffKind.Added, resources.Diff); - Assert.True(resources.Delta > 0); - Assert.Contains(diff.Contributors, c => - c.Kind == SizeNodeKind.Resource && c.Name == "NativeAotConsole.Payload.txt"); + var resources = Assert.ContainsSingle(n => n.Name == "Resources", diff.Root.Children); + Assert.AreEqual(DiffKind.Added, resources.Diff); + Assert.IsGreaterThan(0, resources.Delta); + Assert.Contains(c => + c.Kind == SizeNodeKind.Resource && c.Name == "NativeAotConsole.Payload.txt", diff.Contributors); } /// /// Verifies the recursive tree invariant: every interior node's sizes are exactly the /// sums of its children. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Compare_V1V2_NodeSumsEqualChildSums() { var (v1, v2) = ReadPair(); @@ -130,9 +138,9 @@ public void Compare_V1V2_NodeSumsEqualChildSums() static void AssertSums(SizeDiffNode node) { if (node.Children.Count == 0) return; - Assert.Equal(node.Children.Sum(c => c.LeftSize), node.LeftSize); - Assert.Equal(node.Children.Sum(c => c.RightSize), node.RightSize); - Assert.Equal(node.Children.Sum(c => c.Delta), node.Delta); + Assert.AreEqual(node.Children.Sum(c => c.LeftSize), node.LeftSize); + Assert.AreEqual(node.Children.Sum(c => c.RightSize), node.RightSize); + Assert.AreEqual(node.Children.Sum(c => c.Delta), node.Delta); foreach (var child in node.Children) AssertSums(child); } @@ -144,7 +152,8 @@ static void AssertSums(SizeDiffNode node) /// Verifies contributors are ordered by absolute delta, largest first, and the added /// Telemetry members are among them. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Compare_V1V2_ContributorsOrderedByAbsoluteDelta() { var (v1, v2) = ReadPair(); @@ -153,19 +162,18 @@ public void Compare_V1V2_ContributorsOrderedByAbsoluteDelta() for (var i = 1; i < diff.Contributors.Count; i++) { - Assert.True( - Math.Abs(diff.Contributors[i - 1].Delta) >= Math.Abs(diff.Contributors[i].Delta), - $"contributors out of order at {i}"); + Assert.IsGreaterThanOrEqualTo(Math.Abs(diff.Contributors[i].Delta), Math.Abs(diff.Contributors[i - 1].Delta), $"contributors out of order at {i}"); } - Assert.Contains(diff.Contributors, c => c.Namespace == "NativeAotConsole.Telemetry"); + Assert.Contains(c => c.Namespace == "NativeAotConsole.Telemetry", diff.Contributors); } /// /// Verifies blobs match by name across builds: the Metadata region exists in both, so it /// must appear as one changed-or-unchanged entry, never as an added/removed pair. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Compare_V1V2_BlobsMatchedByName() { var (v1, v2) = ReadPair(); @@ -173,37 +181,38 @@ public void Compare_V1V2_BlobsMatchedByName() var diff = MstatDiffer.Compare(v1, v2); var metadata = diff.Contributors.Where(c => c.FullPath == "Blobs/Metadata").ToList(); - Assert.SkipWhen(metadata.Count == 0, "Metadata blob byte-identical across the builds"); - var entry = Assert.Single(metadata); - Assert.Equal(DiffKind.Changed, entry.Diff); - Assert.True(entry.LeftSize > 0); - Assert.True(entry.RightSize > 0); + TestSkip.When(metadata.Count == 0, "Metadata blob byte-identical across the builds"); + var entry = Assert.ContainsSingle(metadata); + Assert.AreEqual(DiffKind.Changed, entry.Diff); + Assert.IsGreaterThan(0, entry.LeftSize); + Assert.IsGreaterThan(0, entry.RightSize); } /// /// Verifies a self-diff is empty: no changed entries, zero delta, and the unchanged mass /// equals the build total. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Compare_SelfDiff_AllUnchangedZeroDelta() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "V1 mstat sidecar was not produced"); - var v1 = MstatReader.Read(samples.NativeAotConsoleMstat!); - Assert.NotNull(v1); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "V1 mstat sidecar was not produced"); + var v1 = MstatReader.Read(Samples.NativeAotConsoleMstat!); + Assert.IsNotNull(v1); var diff = MstatDiffer.Compare(v1, v1); - Assert.Empty(diff.Root.Children); - Assert.Empty(diff.Contributors); - Assert.Equal(0, diff.Summary.Delta); - Assert.Equal(diff.Summary.LeftTotal, diff.Summary.RightTotal); - Assert.Equal(diff.Summary.LeftTotal, diff.Summary.UnchangedTotal); - Assert.All(diff.Summary.Counts, c => + Assert.IsEmpty(diff.Root.Children); + Assert.IsEmpty(diff.Contributors); + Assert.AreEqual(0, diff.Summary.Delta); + Assert.AreEqual(diff.Summary.LeftTotal, diff.Summary.RightTotal); + Assert.AreEqual(diff.Summary.LeftTotal, diff.Summary.UnchangedTotal); + TestAssert.All(diff.Summary.Counts, c => { - Assert.Equal(0, c.Added); - Assert.Equal(0, c.Removed); - Assert.Equal(0, c.Grown); - Assert.Equal(0, c.Shrunk); + Assert.AreEqual(0, c.Added); + Assert.AreEqual(0, c.Removed); + Assert.AreEqual(0, c.Grown); + Assert.AreEqual(0, c.Shrunk); }); } @@ -212,16 +221,17 @@ public void Compare_SelfDiff_AllUnchangedZeroDelta() /// the System namespace has rows in more than one assembly, and its aggregate must be the /// sum over all of them, recomputed here independently. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Compare_V1V2_NamespaceDeltasFoldAcrossAssemblies() { var (v1, v2) = ReadPair(); var diff = MstatDiffer.Compare(v1, v2); - var telemetry = Assert.Single(diff.NamespaceDeltas, a => a.Name == "NativeAotConsole.Telemetry"); - Assert.Equal(0, telemetry.LeftSize); - Assert.True(telemetry.Delta > 0); + var telemetry = Assert.ContainsSingle(a => a.Name == "NativeAotConsole.Telemetry", diff.NamespaceDeltas); + Assert.AreEqual(0, telemetry.LeftSize); + Assert.IsGreaterThan(0, telemetry.Delta); var policy = MstatSectionPolicy.ForPair(v1, v2); var rightIndex = MstatSizeIndex.Create(v2, policy); @@ -231,21 +241,22 @@ public void Compare_V1V2_NamespaceDeltasFoldAcrossAssemblies() .Select(e => e.AssemblyName) .Distinct() .Count(); - Assert.SkipWhen(assembliesWithSystem < 2, "System namespace spans fewer than two assemblies"); + TestSkip.When(assembliesWithSystem < 2, "System namespace spans fewer than two assemblies"); - var system = Assert.Single(diff.NamespaceDeltas, a => a.Name == "System"); - Assert.Equal(rightIndex.NamespaceTotals["System"], system.RightSize); + var system = Assert.ContainsSingle(a => a.Name == "System", diff.NamespaceDeltas); + Assert.AreEqual(rightIndex.NamespaceTotals["System"], system.RightSize); } /// /// Verifies aggregated entries are marked: the frozen string-literal bucket folds many /// rows and must say so through its entry counts. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Compare_V1V2_AggregatedEntriesCarryCounts() { var (v1, v2) = ReadPair(); - Assert.SkipWhen( + TestSkip.When( v1.FrozenObjects.Count(f => f.OwningType is null) < 2, "fixture froze fewer than two string literals"); @@ -254,32 +265,33 @@ public void Compare_V1V2_AggregatedEntriesCarryCounts() var literals = diff.Contributors.FirstOrDefault(c => c.Kind == SizeNodeKind.FrozenObject && c.AssemblyName == MstatSizeIndex.UnattributedName); - Assert.SkipWhen(literals is null, "frozen literals byte-identical across the builds"); - Assert.True(literals!.LeftEntryCount > 1); - Assert.True(literals.RightEntryCount > 1); - Assert.True(literals.RightNodeNames.Count > 1); + TestSkip.When(literals is null, "frozen literals byte-identical across the builds"); + Assert.IsGreaterThan(1, literals!.LeftEntryCount); + Assert.IsGreaterThan(1, literals.RightEntryCount); + Assert.IsGreaterThan(1, literals.RightNodeNames.Count); } /// /// Verifies an added contributor's node names join the V2 dependency graph — the "why did /// this appear" answer resolves against real labels. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Compare_V1V2_AddedContributorNodeNamesJoinRightDgml() { var (v1, v2) = ReadPair(); - Assert.SkipWhen(samples.NativeAotConsoleV2Dgml is null, "V2 DGML sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleV2Dgml is null, "V2 DGML sidecar was not produced"); var diff = MstatDiffer.Compare(v1, v2); - var dgml = DgmlReader.Read(samples.NativeAotConsoleV2Dgml!); - Assert.NotNull(dgml); + var dgml = DgmlReader.Read(Samples.NativeAotConsoleV2Dgml!); + Assert.IsNotNull(dgml); var added = diff.Contributors.First(c => c.Diff == DiffKind.Added && c.Namespace == "NativeAotConsole.Telemetry" && c.Kind == SizeNodeKind.Method && c.RightNodeNames.Count > 0); - Assert.Contains(added.RightNodeNames, name => dgml.FindNodeByLabel(name) is not null); + Assert.Contains(name => dgml.FindNodeByLabel(name) is not null, added.RightNodeNames); } /// @@ -289,30 +301,31 @@ public void Compare_V1V2_AddedContributorNodeNamesJoinRightDgml() /// produce for real. Entries still match by their name-based keys, so a self-comparison /// against the unpatched report stays empty. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Compare_MstatWithoutNamesSection_MatchesWithEmptyNodeNames() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "V1 mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "V1 mstat sidecar was not produced"); var patched = Path.Combine(Path.GetTempPath(), $"dotsider-nonames-{Guid.NewGuid():N}.mstat"); try { - var bytes = File.ReadAllBytes(samples.NativeAotConsoleMstat!); + var bytes = File.ReadAllBytes(Samples.NativeAotConsoleMstat!); var sectionName = ".names\0\0"u8.ToArray(); var offset = FindBytes(bytes, sectionName); - Assert.SkipWhen(offset < 0, "report carries no .names section"); + TestSkip.When(offset < 0, "report carries no .names section"); bytes[offset + 1] = (byte)'x'; File.WriteAllBytes(patched, bytes); var noNames = MstatReader.Read(patched); - var original = MstatReader.Read(samples.NativeAotConsoleMstat!); - Assert.NotNull(noNames); - Assert.NotNull(original); - Assert.All(noNames.Methods, m => Assert.Null(m.NodeName)); + var original = MstatReader.Read(Samples.NativeAotConsoleMstat!); + Assert.IsNotNull(noNames); + Assert.IsNotNull(original); + TestAssert.All(noNames.Methods, m => Assert.IsNull(m.NodeName)); var diff = MstatDiffer.Compare(noNames, original); - Assert.Empty(diff.Root.Children); - Assert.Equal(0, diff.Summary.Delta); + Assert.IsEmpty(diff.Root.Children); + Assert.AreEqual(0, diff.Summary.Delta); } finally { diff --git a/tests/Dotsider.Tests/MstatLocatorTests.cs b/tests/Dotsider.Tests/MstatLocatorTests.cs index de80ff70..e6c04d02 100644 --- a/tests/Dotsider.Tests/MstatLocatorTests.cs +++ b/tests/Dotsider.Tests/MstatLocatorTests.cs @@ -6,65 +6,71 @@ namespace Dotsider.Tests; /// Tests for — the size-comparison input resolver — against the /// real published samples, plus a byte-truncated copy for the damaged-input path. /// -[Collection("SampleAssemblies")] -public class MstatLocatorTests(SampleAssemblyFixture samples) +[TestClass] +public class MstatLocatorTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// Verifies a bare .mstat resolves with no binary attribution and picks up the DGML /// sitting beside it (the publish target copies both to the same directory). /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Resolve_BareMstat_ReturnsSourceWithDgmlProbe() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - var source = MstatLocator.Resolve(samples.NativeAotConsoleMstat!); + var source = MstatLocator.Resolve(Samples.NativeAotConsoleMstat!); - Assert.NotNull(source); - Assert.Equal(samples.NativeAotConsoleMstat, source.MstatPath); - Assert.Null(source.BinaryPath); - Assert.Null(source.BinaryFileSize); - Assert.NotEmpty(source.Data.Methods); - if (samples.NativeAotConsoleDgml is not null) - Assert.NotNull(source.DgmlPath); + Assert.IsNotNull(source); + Assert.AreEqual(Samples.NativeAotConsoleMstat, source.MstatPath); + Assert.IsNull(source.BinaryPath); + Assert.IsNull(source.BinaryFileSize); + Assert.IsNotEmpty(source.Data.Methods); + if (Samples.NativeAotConsoleDgml is not null) + Assert.IsNotNull(source.DgmlPath); } /// /// Verifies a Native AOT binary resolves through its sidecar discovery and carries its /// file size for the file-size basis. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Resolve_AotBinary_ResolvesSidecar() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "AOT binary was not produced"); - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "AOT binary was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - var source = MstatLocator.Resolve(samples.NativeAotConsoleExe!); + var source = MstatLocator.Resolve(Samples.NativeAotConsoleExe!); - Assert.NotNull(source); - Assert.Equal(samples.NativeAotConsoleExe, source.BinaryPath); - Assert.Equal(new FileInfo(samples.NativeAotConsoleExe!).Length, source.BinaryFileSize); - Assert.NotEmpty(source.Data.Methods); + Assert.IsNotNull(source); + Assert.AreEqual(Samples.NativeAotConsoleExe, source.BinaryPath); + Assert.AreEqual(new FileInfo(Samples.NativeAotConsoleExe!).Length, source.BinaryFileSize); + Assert.IsNotEmpty(source.Data.Methods); } /// /// Verifies a managed assembly is rejected by the bounded probe — it never resolves as an /// mstat even though an mstat is itself a valid managed assembly. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Resolve_ManagedDll_ReturnsNull() { - Assert.Null(MstatLocator.Resolve(samples.RichLibraryDll)); - Assert.Null(MstatLocator.Resolve(samples.HelloWorldDll)); + Assert.IsNull(MstatLocator.Resolve(Samples.RichLibraryDll)); + Assert.IsNull(MstatLocator.Resolve(Samples.HelloWorldDll)); } /// /// Verifies a missing path resolves to null rather than throwing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Resolve_MissingFile_ReturnsNull() { - Assert.Null(MstatLocator.Resolve(Path.Combine(Path.GetTempPath(), "does-not-exist.mstat"))); + Assert.IsNull(MstatLocator.Resolve(Path.Combine(Path.GetTempPath(), "does-not-exist.mstat"))); } /// @@ -72,23 +78,24 @@ public void Resolve_MissingFile_ReturnsNull() /// recovers a partial prefix or gives up with null, and the locator surfaces whichever /// without throwing — the CLI turns null into a precise error. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Resolve_TruncatedMstatCopy_NullOrPartialWithoutThrow() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); var truncated = Path.Combine(Path.GetTempPath(), $"dotsider-truncated-{Guid.NewGuid():N}.mstat"); try { - var bytes = File.ReadAllBytes(samples.NativeAotConsoleMstat!); + var bytes = File.ReadAllBytes(Samples.NativeAotConsoleMstat!); File.WriteAllBytes(truncated, bytes[..(bytes.Length / 3)]); var source = MstatLocator.Resolve(truncated); if (source is not null) { - Assert.Equal(truncated, source.MstatPath); - Assert.Null(source.BinaryPath); + Assert.AreEqual(truncated, source.MstatPath); + Assert.IsNull(source.BinaryPath); } } finally diff --git a/tests/Dotsider.Tests/MstatReaderTests.cs b/tests/Dotsider.Tests/MstatReaderTests.cs index 7867be81..e4b40199 100644 --- a/tests/Dotsider.Tests/MstatReaderTests.cs +++ b/tests/Dotsider.Tests/MstatReaderTests.cs @@ -7,58 +7,63 @@ namespace Dotsider.Tests; /// NativeAOT sample, plus malformed-input cases. The fixture publishes with the .NET 10 SDK, /// so format assertions pin version 2.2. /// -[Collection("SampleAssemblies")] -public class MstatReaderTests(SampleAssemblyFixture samples) +[TestClass] +public class MstatReaderTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// Verifies the fixture's report parses and carries format version 2.2. The assembly /// version's Build/Revision are unset sentinels and must not leak into the format fields. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_FixtureMstat_ReportsFormat22() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - var data = MstatReader.Read(samples.NativeAotConsoleMstat!); + var data = MstatReader.Read(Samples.NativeAotConsoleMstat!); - Assert.NotNull(data); - Assert.Equal(2, data.FormatMajorVersion); - Assert.Equal(2, data.FormatMinorVersion); + Assert.IsNotNull(data); + Assert.AreEqual(2, data.FormatMajorVersion); + Assert.AreEqual(2, data.FormatMinorVersion); } /// /// Verifies methods carry names, non-negative sizes, and assembly attribution spanning /// both the app and the runtime. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_FixtureMstat_MethodsHaveNamesSizesAndAssemblies() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - var data = MstatReader.Read(samples.NativeAotConsoleMstat!); + var data = MstatReader.Read(Samples.NativeAotConsoleMstat!); - Assert.NotNull(data); - Assert.NotEmpty(data.Methods); - Assert.All(data.Methods, m => Assert.True(m.Size >= 0)); - Assert.Contains(data.Methods, m => m.Size > 0); - Assert.All(data.Methods, m => Assert.False(string.IsNullOrEmpty(m.Name))); - Assert.Contains(data.Methods, m => m.AssemblyName == "System.Private.CoreLib"); - Assert.Contains(data.Methods, m => m.AssemblyName == "NativeAotConsole"); + Assert.IsNotNull(data); + Assert.IsNotEmpty(data.Methods); + TestAssert.All(data.Methods, m => Assert.IsGreaterThanOrEqualTo(0, m.Size)); + Assert.Contains(m => m.Size > 0, data.Methods); + TestAssert.All(data.Methods, m => Assert.IsFalse(string.IsNullOrEmpty(m.Name))); + Assert.Contains(m => m.AssemblyName == "System.Private.CoreLib", data.Methods); + Assert.Contains(m => m.AssemblyName == "NativeAotConsole", data.Methods); } /// /// Verifies every method in a 2.x report carries its dependency-graph node name — the /// string that joins the size entry to the DGML graph. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_FixtureMstat_MethodsCarryNodeNames() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - var data = MstatReader.Read(samples.NativeAotConsoleMstat!); + var data = MstatReader.Read(Samples.NativeAotConsoleMstat!); - Assert.NotNull(data); - Assert.All(data.Methods, m => Assert.False(string.IsNullOrEmpty(m.NodeName))); + Assert.IsNotNull(data); + TestAssert.All(data.Methods, m => Assert.IsFalse(string.IsNullOrEmpty(m.NodeName))); } /// @@ -66,45 +71,47 @@ public void Read_FixtureMstat_MethodsCarryNodeNames() /// Greet overloads share a display name but carry distinct rendered parameter lists — /// the identity that keeps overloads apart in a size diff. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_FixtureMstat_MethodSignaturesDecoded() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - var data = MstatReader.Read(samples.NativeAotConsoleMstat!); + var data = MstatReader.Read(Samples.NativeAotConsoleMstat!); - Assert.NotNull(data); + Assert.IsNotNull(data); var greets = data.Methods .Where(m => m.AssemblyName == "NativeAotConsole" && m.Name == "Greet") .ToList(); - Assert.Equal(2, greets.Count); - Assert.Equal(2, greets.Select(m => m.Signature).Distinct().Count()); - Assert.Contains(greets, m => m.Signature == "(string)"); - Assert.Contains(greets, m => m.Signature == "(int)"); + Assert.HasCount(2, greets); + Assert.HasCount(2, greets.Select(m => m.Signature).Distinct()); + Assert.Contains(m => m.Signature == "(string)", greets); + Assert.Contains(m => m.Signature == "(int)", greets); } /// /// Verifies frozen-object owner attribution: string literals carry no owner, so their /// owner attribution fields stay null — the bytes are honestly unattributable. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_FixtureMstat_OwnerlessFrozenObjectsCarryNoOwnerAttribution() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - var data = MstatReader.Read(samples.NativeAotConsoleMstat!); + var data = MstatReader.Read(Samples.NativeAotConsoleMstat!); - Assert.NotNull(data); - Assert.All( + Assert.IsNotNull(data); + TestAssert.All( data.FrozenObjects.Where(f => f.OwningType is null), f => { - Assert.Null(f.OwningAssemblyName); - Assert.Null(f.OwningNamespace); + Assert.IsNull(f.OwningAssemblyName); + Assert.IsNull(f.OwningNamespace); }); - Assert.All( + TestAssert.All( data.FrozenObjects.Where(f => f.OwningType is not null), - f => Assert.NotNull(f.OwningAssemblyName)); + f => Assert.IsNotNull(f.OwningAssemblyName)); } /// @@ -112,96 +119,102 @@ public void Read_FixtureMstat_OwnerlessFrozenObjectsCarryNoOwnerAttribution() /// assemblies — an mstat is itself a valid ECMA-335 assembly, so the probe is what keeps /// the two input kinds apart without a full decode. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Probe_AcceptsMstatRejectsManagedAssembly() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - Assert.True(MstatReader.Probe(samples.NativeAotConsoleMstat!)); - Assert.False(MstatReader.Probe(samples.RichLibraryDll)); - Assert.False(MstatReader.Probe(Path.Combine(Path.GetTempPath(), "missing.mstat"))); + Assert.IsTrue(MstatReader.Probe(Samples.NativeAotConsoleMstat!)); + Assert.IsFalse(MstatReader.Probe(Samples.RichLibraryDll)); + Assert.IsFalse(MstatReader.Probe(Path.Combine(Path.GetTempPath(), "missing.mstat"))); } /// /// Verifies the sample's own Program type appears among the constructed types. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_FixtureMstat_TypesCoverProgramType() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - var data = MstatReader.Read(samples.NativeAotConsoleMstat!); + var data = MstatReader.Read(Samples.NativeAotConsoleMstat!); - Assert.NotNull(data); - Assert.NotEmpty(data.Types); - Assert.Contains(data.Types, t => t.Name == "Program" && t.AssemblyName == "NativeAotConsole"); + Assert.IsNotNull(data); + Assert.IsNotEmpty(data.Types); + Assert.Contains(t => t.Name == "Program" && t.AssemblyName == "NativeAotConsole", data.Types); } /// /// Verifies the well-known global data regions appear among the blobs, including the /// buckets that 2.1+ also breaks down in detail sections. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_FixtureMstat_BlobsIncludeKnownRegions() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - var data = MstatReader.Read(samples.NativeAotConsoleMstat!); + var data = MstatReader.Read(Samples.NativeAotConsoleMstat!); - Assert.NotNull(data); - Assert.Contains(data.Blobs, b => b.Name == "Metadata" && b.Size > 0); - Assert.Contains(data.Blobs, b => b.Name == "ArrayOfFrozenObjects" && b.Size > 0); - Assert.Contains(data.Blobs, b => b.Name == "FieldRvaData" && b.Size > 0); + Assert.IsNotNull(data); + Assert.Contains(b => b.Name == "Metadata" && b.Size > 0, data.Blobs); + Assert.Contains(b => b.Name == "ArrayOfFrozenObjects" && b.Size > 0, data.Blobs); + Assert.Contains(b => b.Name == "FieldRvaData" && b.Size > 0, data.Blobs); } /// /// Verifies the 2.1 detail sections parse: the console sample freezes string literals, so /// frozen objects are non-empty and typed as System.String with no owning type. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_FixtureMstat_FrozenObjectsAreStringLiterals() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - var data = MstatReader.Read(samples.NativeAotConsoleMstat!); + var data = MstatReader.Read(Samples.NativeAotConsoleMstat!); - Assert.NotNull(data); - Assert.NotEmpty(data.FrozenObjects); - Assert.Contains(data.FrozenObjects, f => f.TypeName == "System.String" && f.OwningType is null); - Assert.NotEmpty(data.RvaFields); + Assert.IsNotNull(data); + Assert.IsNotEmpty(data.FrozenObjects); + Assert.Contains(f => f.TypeName == "System.String" && f.OwningType is null, data.FrozenObjects); + Assert.IsNotEmpty(data.RvaFields); } /// /// Verifies the report lists the referenced assemblies, including the app itself — the /// entry the dependency graph promotes to its root. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_FixtureMstat_AssembliesIncludeAppAndCoreLib() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - var data = MstatReader.Read(samples.NativeAotConsoleMstat!); + var data = MstatReader.Read(Samples.NativeAotConsoleMstat!); - Assert.NotNull(data); - Assert.Contains(data.Assemblies, a => a.Name == "NativeAotConsole"); - Assert.Contains(data.Assemblies, a => a.Name == "System.Private.CoreLib"); + Assert.IsNotNull(data); + Assert.Contains(a => a.Name == "NativeAotConsole", data.Assemblies); + Assert.Contains(a => a.Name == "System.Private.CoreLib", data.Assemblies); } /// /// Verifies mstat node names appear as DGML labels — the join contract the dependency /// graph and why-chains rely on. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_FixtureMstat_NodeNamesJoinToDgmlLabels() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - Assert.SkipWhen(samples.NativeAotConsoleDgml is null, "DGML sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleDgml is null, "DGML sidecar was not produced"); - var data = MstatReader.Read(samples.NativeAotConsoleMstat!); - Assert.NotNull(data); + var data = MstatReader.Read(Samples.NativeAotConsoleMstat!); + Assert.IsNotNull(data); var labels = new HashSet(StringComparer.Ordinal); - foreach (var line in File.ReadLines(samples.NativeAotConsoleDgml!)) + foreach (var line in File.ReadLines(Samples.NativeAotConsoleDgml!)) { var start = line.IndexOf("Label=\"", StringComparison.Ordinal); if (start < 0) continue; @@ -212,83 +225,88 @@ public void Read_FixtureMstat_NodeNamesJoinToDgmlLabels() var sample = data.Methods.Take(200).ToList(); var hits = sample.Count(m => m.NodeName is { } n && labels.Contains(n)); - Assert.True(hits >= sample.Count * 9 / 10, - $"only {hits}/{sample.Count} method node names matched DGML labels"); + Assert.IsGreaterThanOrEqualTo(sample.Count * 9 / 10, hits, $"only {hits}/{sample.Count} method node names matched DGML labels"); } /// /// Verifies a managed assembly is rejected: RichLibrary's 2.5 assembly version passes the /// version gate, so the recognized-stream gate must reject it. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_ManagedDll_ReturnsNull() { - Assert.Null(MstatReader.Read(samples.RichLibraryDll)); + Assert.IsNull(MstatReader.Read(Samples.RichLibraryDll)); } /// /// Verifies a non-PE file returns null rather than throwing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_NonPeFile_ReturnsNull() { - Assert.Null(MstatReader.Read(samples.NonDotNetBinaryPath)); + Assert.IsNull(MstatReader.Read(Samples.NonDotNetBinaryPath)); } /// /// Verifies the Native AOT executable itself (no CLR metadata) returns null. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_NativeAotExeItself_ReturnsNull() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - Assert.Null(MstatReader.Read(samples.NativeAotConsoleExe!)); + Assert.IsNull(MstatReader.Read(Samples.NativeAotConsoleExe!)); } /// /// Verifies a missing file returns null rather than throwing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_MissingFile_ReturnsNull() { - Assert.Null(MstatReader.Read(Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.mstat"))); + Assert.IsNull(MstatReader.Read(Path.Combine(Path.GetTempPath(), $"missing-{Guid.NewGuid():N}.mstat"))); } /// /// Verifies a truncated report never throws: the result is null or carries only the /// entries that parsed completely before the cut. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_TruncatedMstat_ReturnsNullOrParsedPrefix() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - var bytes = File.ReadAllBytes(samples.NativeAotConsoleMstat!); + var bytes = File.ReadAllBytes(Samples.NativeAotConsoleMstat!); using var truncated = new MemoryStream(bytes, 0, bytes.Length * 6 / 10); var data = MstatReader.Read(truncated); if (data is not null) - Assert.All(data.Methods, m => Assert.True(m.Size >= 0)); + TestAssert.All(data.Methods, m => Assert.IsGreaterThanOrEqualTo(0, m.Size)); } /// /// Verifies a corrupted IL stream keeps the entries parsed before the damage: flipping an /// opcode mid-stream ends the walk without discarding the prefix or throwing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_CorruptedIlStream_KeepsParsedPrefix() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - var intact = MstatReader.Read(samples.NativeAotConsoleMstat!); - Assert.NotNull(intact); - Assert.True(intact.Methods.Count > 10); + var intact = MstatReader.Read(Samples.NativeAotConsoleMstat!); + Assert.IsNotNull(intact); + Assert.IsGreaterThan(10, intact.Methods.Count); // Find the Methods IL and flip a byte in its middle to a nop. The IL streams live in // .text, so corrupt a byte at ~25% of the file, well past the headers. - var bytes = File.ReadAllBytes(samples.NativeAotConsoleMstat!); + var bytes = File.ReadAllBytes(Samples.NativeAotConsoleMstat!); bytes[bytes.Length / 4] = 0x00; using var corrupted = new MemoryStream(bytes); @@ -296,6 +314,6 @@ public void Read_CorruptedIlStream_KeepsParsedPrefix() // Never throws; either rejected outright or parsed to some prefix. if (data is not null) - Assert.True(data.Methods.Count <= intact.Methods.Count); + Assert.IsLessThanOrEqualTo(intact.Methods.Count, data.Methods.Count); } } diff --git a/tests/Dotsider.Tests/MstatSizeIndexTests.cs b/tests/Dotsider.Tests/MstatSizeIndexTests.cs index b7a89aef..d970563b 100644 --- a/tests/Dotsider.Tests/MstatSizeIndexTests.cs +++ b/tests/Dotsider.Tests/MstatSizeIndexTests.cs @@ -5,17 +5,19 @@ namespace Dotsider.Tests; /// /// Tests for — the shared normalization layer — against the real -/// size reports published next to the NativeAOT samples. The anti-drift property matters +/// size reports published next to the NativeAOT Samples. The anti-drift property matters /// most: the index total must equal the Size Map total for the same build. /// -[Collection("SampleAssemblies")] -public class MstatSizeIndexTests(SampleAssemblyFixture samples) +[TestClass] +public class MstatSizeIndexTests { - private MstatData ReadV1() + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + + private static MstatData ReadV1() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - var data = MstatReader.Read(samples.NativeAotConsoleMstat!); - Assert.NotNull(data); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + var data = MstatReader.Read(Samples.NativeAotConsoleMstat!); + Assert.IsNotNull(data); return data; } @@ -24,24 +26,26 @@ private MstatData ReadV1() /// guarantee that a figure shown by analyze --size equals the same figure in a /// size diff. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Create_FixtureMstat_TotalMatchesSizeAnalyzer() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "AOT binary was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "AOT binary was not produced"); var index = MstatSizeIndex.Create(ReadV1()); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var tree = SizeAnalyzer.BuildSizeTree(analyzer); - Assert.Equal(tree.Size, index.Total); + Assert.AreEqual(tree.Size, index.Total); } /// /// Verifies overloads stay distinct: the fixture's Greeter has Greet(string) and /// Greet(int), which share a display name but never a key. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Create_OverloadsKeyedSeparately() { var index = MstatSizeIndex.Create(ReadV1()); @@ -52,17 +56,18 @@ public void Create_OverloadsKeyedSeparately() && e.DisplayName == "Greet") .ToList(); - Assert.Equal(2, greets.Count); - Assert.Equal(2, greets.Select(e => e.Key).Distinct().Count()); - Assert.Contains(greets, e => e.LeafName.Contains("string", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(greets, e => e.LeafName.Contains("int", StringComparison.OrdinalIgnoreCase)); + Assert.HasCount(2, greets); + Assert.HasCount(2, greets.Select(e => e.Key).Distinct()); + Assert.Contains(e => e.LeafName.Contains("string", StringComparison.OrdinalIgnoreCase), greets); + Assert.Contains(e => e.LeafName.Contains("int", StringComparison.OrdinalIgnoreCase), greets); } /// /// Verifies frozen string literals aggregate into one entry whose row count and node-name /// list expose the aggregation instead of hiding it. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Create_FrozenObjectsAggregatedByTypeAndOwner() { var data = ReadV1(); @@ -71,15 +76,15 @@ public void Create_FrozenObjectsAggregatedByTypeAndOwner() var literalRows = data.FrozenObjects .Where(f => f.TypeName == "System.String" && f.OwningType is null) .ToList(); - Assert.SkipWhen(literalRows.Count < 2, "fixture froze fewer than two string literals"); + TestSkip.When(literalRows.Count < 2, "fixture froze fewer than two string literals"); - var entry = Assert.Single(index.Entries, e => + var entry = Assert.ContainsSingle(e => e.Section == MstatSectionKind.FrozenObject && e.LeafName == "System.String" - && e.AssemblyName == MstatSizeIndex.UnattributedName); - Assert.Equal(literalRows.Count, entry.EntryCount); - Assert.Equal(literalRows.Sum(f => (long)f.Size), entry.Size); - Assert.Equal(literalRows.Count(f => f.NodeName is not null), entry.NodeNames.Count); + && e.AssemblyName == MstatSizeIndex.UnattributedName, index.Entries); + Assert.AreEqual(literalRows.Count, entry.EntryCount); + Assert.AreEqual(literalRows.Sum(f => (long)f.Size), entry.Size); + Assert.HasCount(literalRows.Count(f => f.NodeName is not null), entry.NodeNames); } /// @@ -88,7 +93,8 @@ public void Create_FrozenObjectsAggregatedByTypeAndOwner() /// cannot say — so they land in the explicit unattributed bucket, and the bytes are /// conserved in the aggregates rather than dropped. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Create_FrozenStringLiterals_GoToUnattributedBucket() { var data = ReadV1(); @@ -97,11 +103,11 @@ public void Create_FrozenStringLiterals_GoToUnattributedBucket() var literalBytes = data.FrozenObjects .Where(f => f.OwningType is null) .Sum(f => (long)f.Size); - Assert.SkipWhen(literalBytes == 0, "fixture froze no ownerless objects"); + TestSkip.When(literalBytes == 0, "fixture froze no ownerless objects"); - Assert.True(index.AssemblyTotals.ContainsKey(MstatSizeIndex.UnattributedName)); - Assert.True(index.AssemblyTotals[MstatSizeIndex.UnattributedName] >= literalBytes); - Assert.True(index.NamespaceTotals.ContainsKey(MstatSizeIndex.UnattributedName)); + Assert.IsTrue(index.AssemblyTotals.ContainsKey(MstatSizeIndex.UnattributedName)); + Assert.IsGreaterThanOrEqualTo(literalBytes, index.AssemblyTotals[MstatSizeIndex.UnattributedName]); + Assert.IsTrue(index.NamespaceTotals.ContainsKey(MstatSizeIndex.UnattributedName)); // CoreLib's own total must not include the literal bytes: methods + MethodTables + // owned frozen + RVA + resources attributed to it, recomputed independently. @@ -110,75 +116,78 @@ public void Create_FrozenStringLiterals_GoToUnattributedBucket() .Where(e => e.AssemblyName == "System.Private.CoreLib" && e.Section != MstatSectionKind.Blob) .Sum(e => e.Size); - Assert.Equal(expectedCoreLib, index.AssemblyTotals["System.Private.CoreLib"]); + Assert.AreEqual(expectedCoreLib, index.AssemblyTotals["System.Private.CoreLib"]); } /// /// Verifies owned frozen objects (serialized statics) carry their owner's attribution. /// The console fixture typically has none, in which case this skips honestly. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Create_OwnedFrozenObjects_AttributedToOwnerAssembly() { var data = ReadV1(); var owned = data.FrozenObjects.Where(f => f.OwningType is not null).ToList(); - Assert.SkipWhen(owned.Count == 0, "fixture has no owned frozen objects"); + TestSkip.When(owned.Count == 0, "fixture has no owned frozen objects"); var index = MstatSizeIndex.Create(data); - Assert.All( + TestAssert.All( index.Entries.Where(e => e.Section == MstatSectionKind.FrozenObject && e.TypeName != e.LeafName), - e => Assert.NotEqual(MstatSizeIndex.UnattributedName, e.AssemblyName)); + e => Assert.AreNotEqual(MstatSizeIndex.UnattributedName, e.AssemblyName)); } /// /// Verifies entries carry the structured hierarchy fields — a consumer places every entry /// in an assembly → namespace → type → leaf tree without parsing display strings. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Create_EntriesCarryStructuredHierarchy() { var index = MstatSizeIndex.Create(ReadV1()); - Assert.All( + TestAssert.All( index.Entries.Where(e => e.Section == MstatSectionKind.Method), e => { - Assert.False(string.IsNullOrEmpty(e.TypeName)); - Assert.False(string.IsNullOrEmpty(e.LeafName)); + Assert.IsFalse(string.IsNullOrEmpty(e.TypeName)); + Assert.IsFalse(string.IsNullOrEmpty(e.LeafName)); }); - Assert.All( + TestAssert.All( index.Entries.Where(e => e.Section == MstatSectionKind.MethodTable), - e => Assert.Equal("MethodTable", e.LeafName)); - Assert.All( + e => Assert.AreEqual("MethodTable", e.LeafName)); + TestAssert.All( index.Entries.Where(e => e.Section == MstatSectionKind.RvaField), e => { - Assert.False(string.IsNullOrEmpty(e.TypeName)); - Assert.False(string.IsNullOrEmpty(e.LeafName)); + Assert.IsFalse(string.IsNullOrEmpty(e.TypeName)); + Assert.IsFalse(string.IsNullOrEmpty(e.LeafName)); Assert.DoesNotContain("::", e.LeafName); }); - Assert.All( + TestAssert.All( index.Entries.Where(e => e.Section is MstatSectionKind.Blob or MstatSectionKind.Resource), - e => Assert.Equal("", e.TypeName)); + e => Assert.AreEqual("", e.TypeName)); } /// /// Verifies the shared double-count rule: with 2.1+ detail sections populated, the /// back-compat blob buckets are excluded from the entries so no byte counts twice. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Create_DetailSectionsExcludeBlobBuckets() { var data = ReadV1(); - Assert.SkipWhen(data.FrozenObjects.Count == 0, "fixture has no frozen objects"); + TestSkip.When(data.FrozenObjects.Count == 0, "fixture has no frozen objects"); var index = MstatSizeIndex.Create(data); - Assert.DoesNotContain(index.Entries, e => e.Key == "B|ArrayOfFrozenObjects"); - Assert.DoesNotContain(index.Entries, e => e.Key == "B|FieldRvaData"); - Assert.Contains(index.Entries, e => e.Section == MstatSectionKind.FrozenObject); - Assert.Contains(index.Entries, e => e.Section == MstatSectionKind.RvaField); + Assert.DoesNotContain(e => e.Key == "B|ArrayOfFrozenObjects", index.Entries); + Assert.DoesNotContain(e => e.Key == "B|FieldRvaData", index.Entries); + Assert.Contains(e => e.Section == MstatSectionKind.FrozenObject, index.Entries); + Assert.Contains(e => e.Section == MstatSectionKind.RvaField, index.Entries); } /// @@ -186,7 +195,8 @@ public void Create_DetailSectionsExcludeBlobBuckets() /// and MethodTables — a namespace budget measures all bytes the namespace put in the /// image, recomputed here independently of the index's own arithmetic. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Create_NamespaceTotalsIncludeRvaAndOwnedFrozen() { var index = MstatSizeIndex.Create(ReadV1()); @@ -197,33 +207,33 @@ public void Create_NamespaceTotalsIncludeRvaAndOwnedFrozen() .GroupBy(e => e.Namespace, StringComparer.Ordinal) .ToDictionary(g => g.Key, g => g.Sum(e => e.Size), StringComparer.Ordinal); - Assert.Equal(expected.Count, index.NamespaceTotals.Count); + Assert.HasCount(expected.Count, index.NamespaceTotals); foreach (var (ns, total) in expected) - Assert.Equal(total, index.NamespaceTotals[ns]); + Assert.AreEqual(total, index.NamespaceTotals[ns]); } /// /// Verifies resources never contribute to namespace totals — they carry no namespace, and /// inventing one would corrupt namespace budgets. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Create_ResourcesExcludedFromNamespaceTotals() { - Assert.SkipWhen(samples.NativeAotConsoleV2Mstat is null, "V2 mstat sidecar was not produced"); - var data = MstatReader.Read(samples.NativeAotConsoleV2Mstat!); - Assert.NotNull(data); - Assert.SkipWhen(data.ManifestResources.Count == 0, "V2 fixture embeds no resources"); + TestSkip.When(Samples.NativeAotConsoleV2Mstat is null, "V2 mstat sidecar was not produced"); + var data = MstatReader.Read(Samples.NativeAotConsoleV2Mstat!); + Assert.IsNotNull(data); + TestSkip.When(data.ManifestResources.Count == 0, "V2 fixture embeds no resources"); var index = MstatSizeIndex.Create(data); - var resource = Assert.Single( - index.Entries, e => e.Section == MstatSectionKind.Resource); - Assert.Equal("", resource.Namespace); + var resource = Assert.ContainsSingle(e => e.Section == MstatSectionKind.Resource, index.Entries); + Assert.AreEqual("", resource.Namespace); var namespaceSum = index.NamespaceTotals.Values.Sum(); var attributableSum = index.Entries .Where(e => e.Section is MstatSectionKind.Method or MstatSectionKind.MethodTable or MstatSectionKind.RvaField or MstatSectionKind.FrozenObject) .Sum(e => e.Size); - Assert.Equal(attributableSum, namespaceSum); + Assert.AreEqual(attributableSum, namespaceSum); } } diff --git a/tests/Dotsider.Tests/NativeAddressSpaceTests.cs b/tests/Dotsider.Tests/NativeAddressSpaceTests.cs index 88978db4..22addeff 100644 --- a/tests/Dotsider.Tests/NativeAddressSpaceTests.cs +++ b/tests/Dotsider.Tests/NativeAddressSpaceTests.cs @@ -9,6 +9,7 @@ namespace Dotsider.Tests; /// on the macOS CI runner). Covers the two rebase pointer formats .NET AOT emits: /// image-base-relative offsets (arm64) and absolute targets (x64). /// +[TestClass] public class NativeAddressSpaceTests { private const ulong ImageBase = 0x1_0000_0000; @@ -18,50 +19,53 @@ public class NativeAddressSpaceTests /// to the image base plus the target, ignoring the packed next/bind bits, and that the /// decoded address maps to its file offset. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DecodeChainedRebase_OffsetForm_ResolvesToImageBasePlusTarget() { var space = NativeAddressSpace.Create(BuildMachO()); - Assert.NotNull(space); - Assert.Equal(8, space.PointerSize); - Assert.True(space.MachOChained); - Assert.Equal(ImageBase, space.MachOImageBase); + Assert.IsNotNull(space); + Assert.AreEqual(8, space.PointerSize); + Assert.IsTrue(space.MachOChained); + Assert.AreEqual(ImageBase, space.MachOImageBase); var raw = (0x123UL << 51) | 0x40; // next bits set; low 36 hold the offset var va = NativeAddressSpace.DecodeChainedRebase(raw, offsetForm: true, space.MachOImageBase); - Assert.Equal(ImageBase + 0x40, va); + Assert.AreEqual(ImageBase + 0x40, va); - Assert.True(space.TryGetFileOffset(va, out var fileOffset, out _)); - Assert.Equal(0x40, fileOffset); + Assert.IsTrue(space.TryGetFileOffset(va, out var fileOffset, out _)); + Assert.AreEqual(0x40, fileOffset); } /// /// Verifies the absolute rebase form (DYLD_CHAINED_PTR_64, x64) decodes a pointer to the /// absolute address in its low bits and that the decoded address maps to its file offset. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DecodeChainedRebase_AbsoluteForm_ResolvesToTarget() { var space = NativeAddressSpace.Create(BuildMachO()); - Assert.NotNull(space); + Assert.IsNotNull(space); var raw = (0x123UL << 51) | (ImageBase + 0x80); // next bits set; low 36 hold the address var va = NativeAddressSpace.DecodeChainedRebase(raw, offsetForm: false, space.MachOImageBase); - Assert.Equal(ImageBase + 0x80, va); + Assert.AreEqual(ImageBase + 0x80, va); - Assert.True(space.TryGetFileOffset(va, out var fileOffset, out _)); - Assert.Equal(0x80, fileOffset); + Assert.IsTrue(space.TryGetFileOffset(va, out var fileOffset, out _)); + Assert.AreEqual(0x80, fileOffset); } /// /// Verifies an import bind pointer (high bit set) is left unchanged rather than decoded /// as a local target. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DecodeChainedRebase_BindPointer_IsNotDecoded() { var bind = 0x8000_0000_0000_0000UL | 0x40; - Assert.Equal(bind, NativeAddressSpace.DecodeChainedRebase(bind, offsetForm: true, ImageBase)); + Assert.AreEqual(bind, NativeAddressSpace.DecodeChainedRebase(bind, offsetForm: true, ImageBase)); } /// diff --git a/tests/Dotsider.Tests/NativeAotDetectorTests.cs b/tests/Dotsider.Tests/NativeAotDetectorTests.cs index 7cd1effa..8c72efcd 100644 --- a/tests/Dotsider.Tests/NativeAotDetectorTests.cs +++ b/tests/Dotsider.Tests/NativeAotDetectorTests.cs @@ -6,63 +6,69 @@ namespace Dotsider.Tests; /// /// Tests for the Native AOT detector. /// -[Collection("SampleAssemblies")] -public class NativeAotDetectorTests(SampleAssemblyFixture samples) +[TestClass] +public class NativeAotDetectorTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// Verifies detect on a Native AOT executable returns a validated header. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Detect_NativeAotExe_ReturnsValidatedHeader() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var result = NativeAotDetector.Detect(File.ReadAllBytes(samples.NativeAotConsoleExe!)); + var result = NativeAotDetector.Detect(File.ReadAllBytes(Samples.NativeAotConsoleExe!)); - Assert.NotNull(result); - Assert.True(result.HeaderOffset > 0); - Assert.InRange(result.MajorVersion, (ushort)1, (ushort)100); - Assert.InRange(result.SectionCount, 1, 1000); - Assert.InRange(result.EntrySize, (byte)8, (byte)64); + Assert.IsNotNull(result); + Assert.IsGreaterThan(0, result.HeaderOffset); + Assert.IsInRange((ushort)1, (ushort)100, result.MajorVersion); + Assert.IsInRange(1, 1000, result.SectionCount); + Assert.IsInRange((byte)8, (byte)64, result.EntrySize); } /// /// Verifies detect on a Native AOT executable recovers the runtime version. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Detect_NativeAotExe_FindsRuntimeVersion() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var result = NativeAotDetector.Detect(File.ReadAllBytes(samples.NativeAotConsoleExe!)); + var result = NativeAotDetector.Detect(File.ReadAllBytes(Samples.NativeAotConsoleExe!)); - Assert.NotNull(result); - Assert.NotNull(result.RuntimeVersion); - Assert.Matches(@"^\d+\.\d+\.\d+", result.RuntimeVersion); + Assert.IsNotNull(result); + Assert.IsNotNull(result.RuntimeVersion); + Assert.MatchesRegex(@"^\d+\.\d+\.\d+", result.RuntimeVersion); } /// /// Verifies detect on a managed assembly returns null. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Detect_ManagedDll_ReturnsNull() { - var result = NativeAotDetector.Detect(File.ReadAllBytes(samples.RichLibraryDll)); + var result = NativeAotDetector.Detect(File.ReadAllBytes(Samples.RichLibraryDll)); - Assert.Null(result); + Assert.IsNull(result); } /// /// Verifies detect on a native apphost executable returns null. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Detect_ApphostExe_ReturnsNull() { - var result = NativeAotDetector.Detect(File.ReadAllBytes(samples.HelloWorldExe)); + var result = NativeAotDetector.Detect(File.ReadAllBytes(Samples.HelloWorldExe)); - Assert.Null(result); + Assert.IsNull(result); } /// @@ -70,7 +76,8 @@ public void Detect_ApphostExe_ReturnsNull() /// The field values replicate a real code-immediate collision observed in /// ILC-generated machine code. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Detect_FakeRtrSignatureFailsValidation_ReturnsNull() { var bytes = new byte[512]; @@ -79,13 +86,14 @@ public void Detect_FakeRtrSignatureFailsValidation_ReturnsNull() WriteHeader(bytes, offset: 64, majorVersion: 35649, minorVersion: 18937, sectionCount: 18650, entrySize: 139, entryType: 233, firstSectionId: 15041807); - Assert.Null(NativeAotDetector.Detect(bytes)); + Assert.IsNull(NativeAotDetector.Detect(bytes)); } /// /// Verifies a well-formed synthetic header is accepted. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Detect_SyntheticValidHeader_ReturnsInfo() { var bytes = new byte[4096]; @@ -96,19 +104,20 @@ public void Detect_SyntheticValidHeader_ReturnsInfo() var result = NativeAotDetector.Detect(bytes); - Assert.NotNull(result); - Assert.Equal(128, result.HeaderOffset); - Assert.Equal(16, result.MajorVersion); - Assert.Equal(33, result.SectionCount); - Assert.Equal(24, result.EntrySize); - Assert.Null(result.RuntimeVersion); + Assert.IsNotNull(result); + Assert.AreEqual(128, result.HeaderOffset); + Assert.AreEqual(16, result.MajorVersion); + Assert.AreEqual(33, result.SectionCount); + Assert.AreEqual(24, result.EntrySize); + Assert.IsNull(result.RuntimeVersion); } /// /// Verifies a header whose section entries would run past the end of the /// file is rejected. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Detect_TruncatedHeader_ReturnsNull() { var bytes = new byte[160]; @@ -119,21 +128,22 @@ public void Detect_TruncatedHeader_ReturnsNull() WriteHeader(bytes, offset: 128, majorVersion: 16, minorVersion: 0, sectionCount: 33, entrySize: 24, entryType: 1, firstSectionId: 201); - Assert.Null(NativeAotDetector.Detect(bytes)); + Assert.IsNull(NativeAotDetector.Detect(bytes)); } /// /// Verifies bytes without a PE, ELF, or Mach-O magic are rejected even when /// they contain a well-formed header. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Detect_NoExecutableMagic_ReturnsNull() { var bytes = new byte[4096]; WriteHeader(bytes, offset: 128, majorVersion: 16, minorVersion: 0, sectionCount: 33, entrySize: 24, entryType: 1, firstSectionId: 201); - Assert.Null(NativeAotDetector.Detect(bytes)); + Assert.IsNull(NativeAotDetector.Detect(bytes)); } /// @@ -141,7 +151,8 @@ public void Detect_NoExecutableMagic_ReturnsNull() /// header, mirroring real binaries where a code immediate precedes the /// genuine header. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Detect_FalsePositiveBeforeRealHeader_FindsRealHeader() { var bytes = new byte[8192]; @@ -154,8 +165,8 @@ public void Detect_FalsePositiveBeforeRealHeader_FindsRealHeader() var result = NativeAotDetector.Detect(bytes); - Assert.NotNull(result); - Assert.Equal(2048, result.HeaderOffset); + Assert.IsNotNull(result); + Assert.AreEqual(2048, result.HeaderOffset); } /// @@ -163,51 +174,54 @@ public void Detect_FalsePositiveBeforeRealHeader_FindsRealHeader() /// real Native AOT binary makes detection fail — validation, not just the /// signature, is load-bearing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Detect_NativeAotExe_CorruptedHeader_ReturnsNull() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var bytes = File.ReadAllBytes(samples.NativeAotConsoleExe!); + var bytes = File.ReadAllBytes(Samples.NativeAotConsoleExe!); var info = NativeAotDetector.Detect(bytes); - Assert.NotNull(info); + Assert.IsNotNull(info); BinaryPrimitives.WriteUInt16LittleEndian(bytes.AsSpan(info.HeaderOffset + 4), 35649); - Assert.Null(NativeAotDetector.Detect(bytes)); + Assert.IsNull(NativeAotDetector.Detect(bytes)); } /// /// Verifies the real Native AOT binary truncated just past its header start /// is rejected because the section entries no longer fit. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Detect_NativeAotExe_TruncatedAtHeader_ReturnsNull() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var bytes = File.ReadAllBytes(samples.NativeAotConsoleExe!); + var bytes = File.ReadAllBytes(Samples.NativeAotConsoleExe!); var info = NativeAotDetector.Detect(bytes); - Assert.NotNull(info); + Assert.IsNotNull(info); - Assert.Null(NativeAotDetector.Detect(bytes.AsSpan(0, info.HeaderOffset + 20))); + Assert.IsNull(NativeAotDetector.Detect(bytes.AsSpan(0, info.HeaderOffset + 20))); } /// /// Verifies a self-contained single-file bundle is not classified as Native AOT /// even though the ReadyToRun assemblies inside it contain RTR signatures. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Detect_SelfContainedBundle_ReturnsNull() { - Assert.SkipWhen(samples.SelfContainedConsoleExe is null, + TestSkip.When(Samples.SelfContainedConsoleExe is null, "Self-contained sample was not built"); - var result = NativeAotDetector.Detect(File.ReadAllBytes(samples.SelfContainedConsoleExe!)); + var result = NativeAotDetector.Detect(File.ReadAllBytes(Samples.SelfContainedConsoleExe!)); - Assert.Null(result); + Assert.IsNull(result); } /// @@ -216,7 +230,8 @@ public void Detect_SelfContainedBundle_ReturnsNull() /// the version lands a few hundred bytes past the anchor instead of /// immediately before it as in MSVC-linked PEs. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Detect_VersionAfterAnchor_NearestMatchWins() { var bytes = new byte[8192]; @@ -234,8 +249,8 @@ public void Detect_VersionAfterAnchor_NearestMatchWins() var result = NativeAotDetector.Detect(bytes); - Assert.NotNull(result); - Assert.Equal("10.0.5", result.RuntimeVersion); + Assert.IsNotNull(result); + Assert.AreEqual("10.0.5", result.RuntimeVersion); } /// diff --git a/tests/Dotsider.Tests/NativeAotSessionSocketTests.cs b/tests/Dotsider.Tests/NativeAotSessionSocketTests.cs index defcbfa9..8e63106d 100644 --- a/tests/Dotsider.Tests/NativeAotSessionSocketTests.cs +++ b/tests/Dotsider.Tests/NativeAotSessionSocketTests.cs @@ -11,15 +11,18 @@ namespace Dotsider.Tests; /// /// End-to-end diagnostics-socket tests for Native AOT analysis commands used by MCP session tools. /// -[Collection("SampleAssemblies")] -public class NativeAotSessionSocketTests(SampleAssemblyFixture samples) : IAsyncDisposable +[TestClass] +public class NativeAotSessionSocketTests : IAsyncDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _app; private DotsiderState? _state; private DotsiderDiagnosticsListener? _listener; private CancellationTokenSource? _appCts; + private Task? _appTask; private async Task StartTuiWithDiagnosticsAsync(string path, CancellationToken ct) { @@ -43,13 +46,13 @@ private async Task StartTuiWithDiagnosticsAsync(string path, Cancellatio { WorkloadAdapter = _workload, EnableInputCoalescing = false - }); + }); _listener = new DotsiderDiagnosticsListener(() => _state); - _listener.StartListening(); + _listener.StartListening(overridePid: TestSocketIds.NextPid()); _appCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - _ = _app.RunAsync(_appCts.Token); + _appTask = _app.RunAsync(_appCts.Token); await Task.Delay(100, ct); await TestHelpers.WaitUntilAsync( @@ -60,54 +63,57 @@ await TestHelpers.WaitUntilAsync( } /// get-native-aot-info returns identity facts from a running Native AOT session. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetNativeAotInfo_ReturnsSummary() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var ct = TestContext.Current.CancellationToken; - var socketPath = await StartTuiWithDiagnosticsAsync(samples.NativeAotConsoleExe!, ct); + var ct = CancellationToken.None; + var socketPath = await StartTuiWithDiagnosticsAsync(Samples.NativeAotConsoleExe!, ct); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "get-native-aot-info" }, ct); - Assert.True(response.Success, response.Error); + Assert.IsTrue(response.Success, response.Error); var data = (response.Data as JsonElement?)!.Value; - Assert.Equal("nativeAot", data.GetProperty("binaryKind").GetString()); - Assert.True(data.GetProperty("readyToRunSections").GetInt32() > 0); + Assert.AreEqual("nativeAot", data.GetProperty("binaryKind").GetString()); + Assert.IsGreaterThan(0, data.GetProperty("readyToRunSections").GetInt32()); } /// get-current-view reports the native tab 3 label for Native AOT sessions. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetCurrentView_NativeAot_Tab3LabelIsDisassembly() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var ct = TestContext.Current.CancellationToken; - var socketPath = await StartTuiWithDiagnosticsAsync(samples.NativeAotConsoleExe!, ct); + var ct = CancellationToken.None; + var socketPath = await StartTuiWithDiagnosticsAsync(Samples.NativeAotConsoleExe!, ct); var navigate = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "navigate", TabId = TabId.IlInspector + 1 }, ct); - Assert.True(navigate.Success, navigate.Error); + Assert.IsTrue(navigate.Success, navigate.Error); await Task.Delay(500, ct); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "get-current-view" }, ct); - Assert.True(response.Success, response.Error); + Assert.IsTrue(response.Success, response.Error); var data = (response.Data as JsonElement?)!.Value; - Assert.Equal(3, data.GetProperty("tab").GetInt32()); - Assert.Equal("Disassembly", data.GetProperty("tabLabel").GetString()); + Assert.AreEqual(3, data.GetProperty("tab").GetInt32()); + Assert.AreEqual("Disassembly", data.GetProperty("tabLabel").GetString()); } /// get-native-aot-size-contributors returns mstat contributors from a running session. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetNativeAotSizeContributors_ReturnsRows() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - var ct = TestContext.Current.CancellationToken; - var socketPath = await StartTuiWithDiagnosticsAsync(samples.NativeAotConsoleExe!, ct); + var ct = CancellationToken.None; + var socketPath = await StartTuiWithDiagnosticsAsync(Samples.NativeAotConsoleExe!, ct); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest @@ -117,28 +123,29 @@ public async Task GetNativeAotSizeContributors_ReturnsRows() TopN = 5 }, ct); - Assert.True(response.Success, response.Error); + Assert.IsTrue(response.Success, response.Error); var data = (response.Data as JsonElement?)!.Value; - Assert.True(data.GetProperty("contributors").GetArrayLength() > 0); + Assert.IsGreaterThan(0, data.GetProperty("contributors").GetArrayLength()); } /// list-methods uses the recovered Native AOT inventory through the session socket. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ListMethods_ReturnsRecoveredNativeAotMethods() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var ct = TestContext.Current.CancellationToken; - var socketPath = await StartTuiWithDiagnosticsAsync(samples.NativeAotConsoleExe!, ct); + var ct = CancellationToken.None; + var socketPath = await StartTuiWithDiagnosticsAsync(Samples.NativeAotConsoleExe!, ct); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "list-methods", TypeName = "Program" }, ct); - Assert.True(response.Success, response.Error); + Assert.IsTrue(response.Success, response.Error); var data = (response.Data as JsonElement?)!.Value; - Assert.True(data.GetArrayLength() > 0); - Assert.All(data.EnumerateArray(), row => - Assert.Equal("RecoveredNativeAot", row.GetProperty("source").GetString())); + Assert.IsGreaterThan(0, data.GetArrayLength()); + TestAssert.All(data.EnumerateArray(), row => + Assert.AreEqual("RecoveredNativeAot", row.GetProperty("source").GetString())); } /// Disposes the diagnostics listener, state, and terminal. @@ -148,7 +155,13 @@ public async ValueTask DisposeAsync() _appCts?.Cancel(); if (_listener is not null) await _listener.DisposeAsync(); + if (_appTask is not null) + { + try { await _appTask; } + catch (OperationCanceledException) { } + } _state?.Dispose(); + _app?.Dispose(); if (_terminal is not null) await _terminal.DisposeAsync(); _appCts?.Dispose(); diff --git a/tests/Dotsider.Tests/NativeArchitectureDecoderTests.cs b/tests/Dotsider.Tests/NativeArchitectureDecoderTests.cs index 7cce1e20..c044ede6 100644 --- a/tests/Dotsider.Tests/NativeArchitectureDecoderTests.cs +++ b/tests/Dotsider.Tests/NativeArchitectureDecoderTests.cs @@ -8,71 +8,72 @@ namespace Dotsider.Tests; /// These byte-level tests exercise the public disassembly API directly. /// Real ReadyToRun fixture tests cover SDK-emitted architecture bodies. /// +[TestClass] public class NativeArchitectureDecoderTests { /// /// Verifies x86 bytes decode as 32-bit x86, not x64 REX prefixes. /// - [Fact] + [TestMethod] public void X86_Decodes32BitOpcodesWithoutRexFallback() { var insns = NativeDisassembler.Disassemble([0x40, 0xC3], 0x1000, NativeArchitecture.X86); - Assert.Equal(2, insns.Count); - Assert.Equal("inc", insns[0].Mnemonic); - Assert.Equal("eax", insns[0].Operands[0].Register); - Assert.Equal(1, insns[0].Length); - Assert.Equal("ret", insns[1].Mnemonic); - Assert.Equal(NativeFlowKind.Return, insns[1].Flow); + Assert.HasCount(2, insns); + Assert.AreEqual("inc", insns[0].Mnemonic); + Assert.AreEqual("eax", insns[0].Operands[0].Register); + Assert.AreEqual(1, insns[0].Length); + Assert.AreEqual("ret", insns[1].Mnemonic); + Assert.AreEqual(NativeFlowKind.Return, insns[1].Flow); } /// /// Verifies Thumb-16 instructions and returns decode structurally. /// - [Fact] + [TestMethod] public void Arm32_DecodesThumb16AndReturn() { var insns = NativeDisassembler.Disassemble([0x01, 0x20, 0x70, 0x47], 0x2000, NativeArchitecture.Arm32); - Assert.Equal(2, insns.Count); - Assert.Equal("movs", insns[0].Mnemonic); - Assert.Equal("r0", insns[0].Operands[0].Register); - Assert.Equal(2, insns[0].Length); - Assert.Equal("bx", insns[1].Mnemonic); - Assert.Equal(NativeFlowKind.Return, insns[1].Flow); + Assert.HasCount(2, insns); + Assert.AreEqual("movs", insns[0].Mnemonic); + Assert.AreEqual("r0", insns[0].Operands[0].Register); + Assert.AreEqual(2, insns[0].Length); + Assert.AreEqual("bx", insns[1].Mnemonic); + Assert.AreEqual(NativeFlowKind.Return, insns[1].Flow); } /// /// Verifies a Thumb-2 instruction is kept as one four-byte instruction. /// - [Fact] + [TestMethod] public void Arm32_DecodesThumb32AsOneInstruction() { var insns = NativeDisassembler.Disassemble([0x00, 0xF0, 0x00, 0xF8], 0x3000, NativeArchitecture.Arm32); - var insn = Assert.Single(insns); - Assert.Equal("bl", insn.Mnemonic); - Assert.Equal(4, insn.Length); - Assert.Equal(NativeFlowKind.Call, insn.Flow); + var insn = Assert.ContainsSingle(insns); + Assert.AreEqual("bl", insn.Mnemonic); + Assert.AreEqual(4, insn.Length); + Assert.AreEqual(NativeFlowKind.Call, insn.Flow); } /// /// Verifies Thumb undefined traps decode as real padding, not fallback. /// - [Fact] + [TestMethod] public void Arm32_DecodesThumbUdfTrapPadding() { - var insn = Assert.Single(NativeDisassembler.Disassemble([0x01, 0xDE], 0x3100, NativeArchitecture.Arm32)); + var insn = Assert.ContainsSingle(NativeDisassembler.Disassemble([0x01, 0xDE], 0x3100, NativeArchitecture.Arm32)); - Assert.Equal("udf", insn.Mnemonic); - Assert.Equal("#0x1", insn.OperandText); - Assert.False(insn.IsFallback); + Assert.AreEqual("udf", insn.Mnemonic); + Assert.AreEqual("#0x1", insn.OperandText); + Assert.IsFalse(insn.IsFallback); } /// /// Verifies RV64 base instructions and canonical returns decode structurally. /// - [Fact] + [TestMethod] public void RiscV64_DecodesBaseAndReturn() { var insns = NativeDisassembler.Disassemble( @@ -80,30 +81,30 @@ public void RiscV64_DecodesBaseAndReturn() 0x4000, NativeArchitecture.RiscV64); - Assert.Equal(2, insns.Count); - Assert.Equal("addi", insns[0].Mnemonic); - Assert.Equal("a0", insns[0].Operands[0].Register); - Assert.Equal(4, insns[0].Length); - Assert.Equal("jalr", insns[1].Mnemonic); - Assert.Equal(NativeFlowKind.Return, insns[1].Flow); + Assert.HasCount(2, insns); + Assert.AreEqual("addi", insns[0].Mnemonic); + Assert.AreEqual("a0", insns[0].Operands[0].Register); + Assert.AreEqual(4, insns[0].Length); + Assert.AreEqual("jalr", insns[1].Mnemonic); + Assert.AreEqual(NativeFlowKind.Return, insns[1].Flow); } /// /// Verifies compressed RISC-V instructions retain their two-byte length. /// - [Fact] + [TestMethod] public void RiscV64_DecodesCompressedNop() { - var insn = Assert.Single(NativeDisassembler.Disassemble([0x01, 0x00], 0x5000, NativeArchitecture.RiscV64)); + var insn = Assert.ContainsSingle(NativeDisassembler.Disassemble([0x01, 0x00], 0x5000, NativeArchitecture.RiscV64)); - Assert.Equal("c.nop", insn.Mnemonic); - Assert.Equal(2, insn.Length); + Assert.AreEqual("c.nop", insn.Mnemonic); + Assert.AreEqual(2, insn.Length); } /// /// Verifies LoongArch64 runtime-table rows decode structurally. /// - [Fact] + [TestMethod] public void LoongArch64_DecodesRuntimeTableRows() { var insns = NativeDisassembler.Disassemble( @@ -111,27 +112,27 @@ public void LoongArch64_DecodesRuntimeTableRows() 0x6000, NativeArchitecture.LoongArch64); - Assert.Equal(2, insns.Count); - Assert.Equal("nop", insns[0].Mnemonic); - Assert.Equal(4, insns[0].Length); - Assert.Equal("addi.d", insns[1].Mnemonic); - Assert.Equal("r4", insns[1].Operands[0].Register); + Assert.HasCount(2, insns); + Assert.AreEqual("nop", insns[0].Mnemonic); + Assert.AreEqual(4, insns[0].Length); + Assert.AreEqual("addi.d", insns[1].Mnemonic); + Assert.AreEqual("r4", insns[1].Operands[0].Register); } /// /// Verifies Wasm LEB immediates and calls decode structurally. /// - [Fact] + [TestMethod] public void Wasm32_DecodesLebAndCall() { var insns = NativeDisassembler.Disassemble([0x41, 0x2A, 0x10, 0x03, 0x0B], 0, NativeArchitecture.Wasm32); - Assert.Equal(3, insns.Count); - Assert.Equal("i32.const", insns[0].Mnemonic); - Assert.Equal(42, insns[0].Operands[0].Immediate); - Assert.Equal(2, insns[0].Length); - Assert.Equal("call", insns[1].Mnemonic); - Assert.Equal(NativeFlowKind.Call, insns[1].Flow); - Assert.Equal("end", insns[2].Mnemonic); + Assert.HasCount(3, insns); + Assert.AreEqual("i32.const", insns[0].Mnemonic); + Assert.AreEqual(42, insns[0].Operands[0].Immediate); + Assert.AreEqual(2, insns[0].Length); + Assert.AreEqual("call", insns[1].Mnemonic); + Assert.AreEqual(NativeFlowKind.Call, insns[1].Flow); + Assert.AreEqual("end", insns[2].Mnemonic); } } diff --git a/tests/Dotsider.Tests/NativeDecoderRegistryTests.cs b/tests/Dotsider.Tests/NativeDecoderRegistryTests.cs index 7675a8fc..1b4a4131 100644 --- a/tests/Dotsider.Tests/NativeDecoderRegistryTests.cs +++ b/tests/Dotsider.Tests/NativeDecoderRegistryTests.cs @@ -8,6 +8,7 @@ namespace Dotsider.Tests; /// These tests prevent new enum values from silently falling through to an unrelated decoder. /// Unknown remains the only intentionally unsupported architecture state. /// +[TestClass] public class NativeDecoderRegistryTests { /// @@ -15,7 +16,7 @@ public class NativeDecoderRegistryTests /// This keeps support table-driven instead of hidden in a switch default. /// A new enum value must add a decoder before it can pass this test. /// - [Fact] + [TestMethod] public void ConcreteArchitectures_AreRegistered() { var expected = Enum.GetValues() @@ -27,7 +28,7 @@ public void ConcreteArchitectures_AreRegistered() .Order() .ToArray(); - Assert.Equal(expected, actual); + Assert.AreSequenceEqual(expected, actual); } /// @@ -35,12 +36,12 @@ public void ConcreteArchitectures_AreRegistered() /// This documents that recognized .NET architectures must decode. /// The public disassembler therefore never guesses x64 for another enum value. /// - [Fact] + [TestMethod] public void Unknown_IsOnlyUnsupportedArchitecture() { foreach (var architecture in Enum.GetValues()) { - Assert.Equal( + Assert.AreEqual( architecture != NativeArchitecture.Unknown, NativeDecoderRegistry.IsSupported(architecture)); } @@ -51,14 +52,14 @@ public void Unknown_IsOnlyUnsupportedArchitecture() /// The public disassembler still emits exact fallback bytes for raw unknown input. /// That fallback is honest and does not claim a concrete instruction set. /// - [Fact] + [TestMethod] public void Unknown_DisassemblesAsByteFallback() { - Assert.False(NativeDecoderRegistry.TryDecode( + Assert.IsFalse(NativeDecoderRegistry.TryDecode( NativeArchitecture.Unknown, [0x40], 0, 0x1000, out _)); - var insn = Assert.Single(NativeDisassembler.Disassemble([0x40], 0x1000, NativeArchitecture.Unknown)); - Assert.Equal(".byte", insn.Mnemonic); - Assert.True(insn.IsFallback); + var insn = Assert.ContainsSingle(NativeDisassembler.Disassemble([0x40], 0x1000, NativeArchitecture.Unknown)); + Assert.AreEqual(".byte", insn.Mnemonic); + Assert.IsTrue(insn.IsFallback); } } diff --git a/tests/Dotsider.Tests/NativeDecorationSmokeTests.cs b/tests/Dotsider.Tests/NativeDecorationSmokeTests.cs index c0ca2fa4..5199cc5d 100644 --- a/tests/Dotsider.Tests/NativeDecorationSmokeTests.cs +++ b/tests/Dotsider.Tests/NativeDecorationSmokeTests.cs @@ -10,17 +10,20 @@ namespace Dotsider.Tests; /// Smoke test that the native syntax and navigation decoration providers never throw or emit an /// out-of-range span over real disassembled functions — the per-frame render path in native mode. /// -[Collection("SampleAssemblies")] -public class NativeDecorationSmokeTests(SampleAssemblyFixture samples) +[TestClass] +public class NativeDecorationSmokeTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// Runs both providers over every managed function's real listing and asserts no throw / in-range spans. - [Fact(Timeout = 120_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Providers_OverRealFunctions_NeverThrowOrExceedLine() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null || !File.Exists(samples.NativeAotConsoleExe), + TestSkip.When(Samples.NativeAotConsoleExe is null || !File.Exists(Samples.NativeAotConsoleExe), "NativeAOT publish did not run on this leg."); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var symbols = analyzer.NativeSymbols!; var syntax = new NativeSyntaxDecorationProvider(); var nav = new NativeNavigationDecorationProvider(); @@ -39,13 +42,12 @@ public void Providers_OverRealFunctions_NeverThrowOrExceedLine() foreach (var span in spans) { var lineLen = doc.GetLineText(span.Start.Line).Length; - Assert.True(span.End.Column <= lineLen + 1, - $"{s.ManagedName ?? s.Name} line {span.Start.Line}: end col {span.End.Column} > line length {lineLen}"); + Assert.IsLessThanOrEqualTo(lineLen + 1, span.End.Column, $"{s.ManagedName ?? s.Name} line {span.Start.Line}: end col {span.End.Column} > line length {lineLen}"); } checkedFns++; } - Assert.True(checkedFns > 0); + Assert.IsGreaterThan(0, checkedFns); } } diff --git a/tests/Dotsider.Tests/NativeDisasmAotFixtureTests.cs b/tests/Dotsider.Tests/NativeDisasmAotFixtureTests.cs index 24f5dd6d..37ffde01 100644 --- a/tests/Dotsider.Tests/NativeDisasmAotFixtureTests.cs +++ b/tests/Dotsider.Tests/NativeDisasmAotFixtureTests.cs @@ -10,12 +10,14 @@ namespace Dotsider.Tests; /// .byte/.word fallback — the "zero fallback on real code" bar. The HardwareIntrinsics /// sample additionally proves the vectorized/intrinsic surface decodes (a vector-register operand /// appears). Runtime helpers that embed jump tables are excluded, since a linear sweep cannot avoid -/// their inline data. These gate with when the AOT publish did not run +/// their inline data. These gate with when the AOT publish did not run /// (no toolchain on the leg). /// -[Collection("SampleAssemblies")] -public class NativeDisasmAotFixtureTests(SampleAssemblyFixture samples) +[TestClass] +public class NativeDisasmAotFixtureTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private static NativeArchitecture ArchOf(AssemblyAnalyzer a) => a.Architecture.ToUpperInvariant() switch { "X64" => NativeArchitecture.X64, @@ -24,42 +26,44 @@ public class NativeDisasmAotFixtureTests(SampleAssemblyFixture samples) }; /// Verifies the sample's own managed methods decode with no desync and zero fallback. - [Fact(Timeout = 120_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeAotConsole_ManagedFunctions_DecodeCleanly() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null || !File.Exists(samples.NativeAotConsoleExe), + TestSkip.When(Samples.NativeAotConsoleExe is null || !File.Exists(Samples.NativeAotConsoleExe), "NativeAOT publish did not run on this leg."); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var arch = ArchOf(analyzer); - Assert.NotEqual(NativeArchitecture.Unknown, arch); + Assert.AreNotEqual(NativeArchitecture.Unknown, arch); var symbols = analyzer.NativeSymbols; - Assert.NotNull(symbols); + Assert.IsNotNull(symbols); var checkedFns = 0; foreach (var (code, name) in ManagedFunctions(analyzer, symbols!)) { var insns = NativeDisassembler.Disassemble(code, 0, arch); - Assert.Equal(code.Length, insns.Sum(i => i.Length)); + Assert.AreEqual(code.Length, insns.Sum(i => i.Length)); var fallback = insns.FirstOrDefault(i => i.IsFallback); - Assert.True(fallback is null, $"{name} @+0x{fallback?.Address:x}: unexpected fallback {fallback?.Mnemonic} {fallback?.OperandText} (bytes {Hex(fallback)})"); + Assert.IsNull(fallback, $"{name} @+0x{fallback?.Address:x}: unexpected fallback {fallback?.Mnemonic} {fallback?.OperandText} (bytes {Hex(fallback)})"); checkedFns++; } - Assert.True(checkedFns > 0, "no managed functions were available to check"); + Assert.IsGreaterThan(0, checkedFns, "no managed functions were available to check"); } /// Verifies the intrinsic sample decodes cleanly and its vectorized code produces vector operands. - [Fact(Timeout = 120_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HardwareIntrinsics_ManagedFunctions_DecodeAndVectorize() { - Assert.SkipWhen(samples.HardwareIntrinsicsExe is null || !File.Exists(samples.HardwareIntrinsicsExe), + TestSkip.When(Samples.HardwareIntrinsicsExe is null || !File.Exists(Samples.HardwareIntrinsicsExe), "HardwareIntrinsics publish did not run on this leg."); - using var analyzer = new AssemblyAnalyzer(samples.HardwareIntrinsicsExe!); + using var analyzer = new AssemblyAnalyzer(Samples.HardwareIntrinsicsExe!); var arch = ArchOf(analyzer); var symbols = analyzer.NativeSymbols; - Assert.NotNull(symbols); + Assert.IsNotNull(symbols); // Scope to the sample's own intrinsic methods (the X64.* families on x64, the Arm.* families // on arm64), identified by their ILC-mangled symbol name. These are pure vector/scalar code: @@ -73,7 +77,7 @@ public void HardwareIntrinsics_ManagedFunctions_DecodeAndVectorize() && fo + s.Size <= raw.Length && (s.Name.Contains("HardwareIntrinsics_X64") || s.Name.Contains("HardwareIntrinsics_Arm"))) .ToList(); - Assert.NotEmpty(intrinsics); // the intrinsic families must be present as function symbols + Assert.IsNotEmpty(intrinsics); // the intrinsic families must be present as function symbols var sawVector = false; var funcDetails = new List(); @@ -81,9 +85,9 @@ public void HardwareIntrinsics_ManagedFunctions_DecodeAndVectorize() { var code = raw.Span.Slice((int)s.FileOffset!.Value, (int)s.Size).ToArray(); var insns = NativeDisassembler.Disassemble(code, 0, arch); - Assert.Equal(code.Length, insns.Sum(i => i.Length)); + Assert.AreEqual(code.Length, insns.Sum(i => i.Length)); var fallback = insns.FirstOrDefault(i => i.IsFallback); - Assert.True(fallback is null, $"{s.Name} @+0x{fallback?.Address:x}: unexpected fallback {fallback?.Mnemonic} {fallback?.OperandText} (bytes {Hex(fallback)})"); + Assert.IsNull(fallback, $"{s.Name} @+0x{fallback?.Address:x}: unexpected fallback {fallback?.Mnemonic} {fallback?.OperandText} (bytes {Hex(fallback)})"); sawVector |= insns.Any(i => i.Category is NativeInstructionCategory.Vector or NativeInstructionCategory.Float && i.Operands.Any(o => o.Register is { } r @@ -92,25 +96,26 @@ public void HardwareIntrinsics_ManagedFunctions_DecodeAndVectorize() funcDetails.Add($"{s.Name}:{{{string.Join(",", insns.Select(i => i.Mnemonic).Distinct())}}}"); } - Assert.True(sawVector, $"no vector-register operand across the intrinsic methods; funcs=[{string.Join(" | ", funcDetails)}]"); + Assert.IsTrue(sawVector, $"no vector-register operand across the intrinsic methods; funcs=[{string.Join(" | ", funcDetails)}]"); } private static string Hex(NativeInstruction? insn) => insn is null ? "—" : string.Join(" ", insn.Bytes.Select(b => b.ToString("x2"))); /// The reader populates the real architecture and a source map, so the disassembler can name the slice and annotate file:line. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeAotConsole_Architecture_And_SourceMap_Populated() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null || !File.Exists(samples.NativeAotConsoleExe), + TestSkip.When(Samples.NativeAotConsoleExe is null || !File.Exists(Samples.NativeAotConsoleExe), "NativeAOT publish did not run on this leg."); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var info = analyzer.NativeSymbols; - Assert.NotNull(info); + Assert.IsNotNull(info); // The architecture reads from the image header, so it is always the real slice arch. - Assert.NotEqual(NativeArchitecture.Unknown, info!.Architecture); + Assert.AreNotEqual(NativeArchitecture.Unknown, info!.Architecture); // Where the sidecar carries line data, the aggregated map must resolve it; a leg whose symbols // are stripped of line data has no map, which is correct rather than a failure. @@ -118,20 +123,21 @@ public void NativeAotConsole_Architecture_And_SourceMap_Populated() s.Kind == NativeSymbolKind.Function && s.SourceFile is not null && s.Line is > 0); if (fn is not null) { - Assert.NotNull(info.SourceMap); - Assert.True(info.SourceMap!.TryGetLine(fn.VirtualAddress, out _, out var line) && line > 0); + Assert.IsNotNull(info.SourceMap); + Assert.IsTrue(info.SourceMap!.TryGetLine(fn.VirtualAddress, out _, out var line) && line > 0); } } /// A truncated instruction tail renders as .byte so summed lengths still equal the window — nothing is dropped. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Disassemble_TruncatedTail_SumsToWindow() { // A 3-byte x64 window where the last instruction (a 4-byte lea) is truncated at the boundary. byte[] code = [0x90, 0x8D, 0x05]; // nop, then a truncated lea eax,[rip+...] var insns = NativeDisassembler.Disassemble(code, 0x1000, NativeArchitecture.X64); - Assert.Equal(code.Length, insns.Sum(i => i.Length)); - Assert.Contains(insns, i => i.IsFallback); + Assert.AreEqual(code.Length, insns.Sum(i => i.Length)); + Assert.Contains(i => i.IsFallback, insns); } private static IEnumerable<(byte[] Code, string Name)> ManagedFunctions(AssemblyAnalyzer analyzer, NativeSymbolInfo symbols) diff --git a/tests/Dotsider.Tests/NativeDisasmFixtureGoldenTests.cs b/tests/Dotsider.Tests/NativeDisasmFixtureGoldenTests.cs index 74ed0bf9..91b071dc 100644 --- a/tests/Dotsider.Tests/NativeDisasmFixtureGoldenTests.cs +++ b/tests/Dotsider.Tests/NativeDisasmFixtureGoldenTests.cs @@ -10,6 +10,7 @@ namespace Dotsider.Tests; /// These fixtures keep deterministic byte-level decoder coverage in the repository. /// Real sample assembly tests cover crossgen2 output where the SDK can produce it locally. /// +[TestClass] public sealed class NativeDisasmFixtureGoldenTests { /// @@ -17,14 +18,14 @@ public sealed class NativeDisasmFixtureGoldenTests /// Length sums must match the fixture byte count and valid fixture rows must not fallback. /// The fixture metadata records the oracle source used when reviewing the byte sequence. /// - [Fact] + [TestMethod] public void FixtureGoldens_DecodeExpectedInstructions() { string root = FindRepositoryRoot(); string fixtureRoot = Path.Combine(root, "tests", "Dotsider.Tests", "Fixtures", "Disasm"); string[] fixtures = Directory.GetFiles(fixtureRoot, "*.json", SearchOption.AllDirectories); - Assert.NotEmpty(fixtures); + Assert.IsNotEmpty(fixtures); foreach (string fixture in fixtures) { using JsonDocument document = JsonDocument.Parse(File.ReadAllText(fixture)); @@ -33,17 +34,17 @@ public void FixtureGoldens_DecodeExpectedInstructions() ulong baseAddress = ParseHexUlong(rootElement.GetProperty("baseAddress").GetString()!); byte[] bytes = ParseHexBytes(rootElement.GetProperty("hex").GetString()!); - Assert.True(rootElement.TryGetProperty("oracle", out JsonElement oracle), fixture); - Assert.False(string.IsNullOrWhiteSpace(oracle.GetProperty("kind").GetString()), fixture); - Assert.True(rootElement.TryGetProperty("runtimeFiles", out JsonElement runtimeFiles), fixture); - Assert.NotEmpty(runtimeFiles.EnumerateArray()); + Assert.IsTrue(rootElement.TryGetProperty("oracle", out JsonElement oracle), fixture); + Assert.IsFalse(string.IsNullOrWhiteSpace(oracle.GetProperty("kind").GetString()), fixture); + Assert.IsTrue(rootElement.TryGetProperty("runtimeFiles", out JsonElement runtimeFiles), fixture); + Assert.IsNotEmpty(runtimeFiles.EnumerateArray()); IReadOnlyList instructions = NativeDisassembler.Disassemble(bytes, baseAddress, architecture); JsonElement.ArrayEnumerator expected = rootElement.GetProperty("expected").EnumerateArray(); var expectedRows = expected.ToArray(); - Assert.Equal(expectedRows.Length, instructions.Count); - Assert.Equal(bytes.Length, instructions.Sum(static instruction => instruction.Length)); + Assert.HasCount(expectedRows.Length, instructions); + Assert.AreEqual(bytes.Length, instructions.Sum(static instruction => instruction.Length)); for (var i = 0; i < expectedRows.Length; i++) { @@ -51,26 +52,26 @@ public void FixtureGoldens_DecodeExpectedInstructions() NativeInstruction instruction = instructions[i]; int offset = expectedRow.GetProperty("offset").GetInt32(); - Assert.Equal(baseAddress + (ulong)offset, instruction.Address); - Assert.Equal(expectedRow.GetProperty("mnemonic").GetString(), instruction.Mnemonic); - Assert.Equal(expectedRow.GetProperty("length").GetInt32(), instruction.Length); - Assert.Equal( + Assert.AreEqual(baseAddress + (ulong)offset, instruction.Address); + Assert.AreEqual(expectedRow.GetProperty("mnemonic").GetString(), instruction.Mnemonic); + Assert.AreEqual(expectedRow.GetProperty("length").GetInt32(), instruction.Length); + Assert.AreEqual( Enum.Parse(expectedRow.GetProperty("flow").GetString()!), instruction.Flow); if (expectedRow.TryGetProperty("operandText", out JsonElement operandText)) { - Assert.Equal(operandText.GetString(), instruction.OperandText); + Assert.AreEqual(operandText.GetString(), instruction.OperandText); } if (expectedRow.TryGetProperty("target", out JsonElement target)) { - Assert.Equal(ParseHexUlong(target.GetString()!), instruction.TargetAddress); + Assert.AreEqual(ParseHexUlong(target.GetString()!), instruction.TargetAddress); } if (!expectedRow.TryGetProperty("fallback", out JsonElement fallback) || !fallback.GetBoolean()) { - Assert.False(instruction.IsFallback, $"{fixture}: {instruction.Address:x} {instruction.Mnemonic} {instruction.OperandText}"); + Assert.IsFalse(instruction.IsFallback, $"{fixture}: {instruction.Address:x} {instruction.Mnemonic} {instruction.OperandText}"); } } } @@ -99,7 +100,7 @@ private static ulong ParseHexUlong(string value) private static byte[] ParseHexBytes(string value) { string compact = string.Concat(value.Where(static c => !char.IsWhiteSpace(c))); - Assert.True(compact.Length % 2 == 0, value); + Assert.AreEqual(0, compact.Length % 2, value); var bytes = new byte[compact.Length / 2]; for (var i = 0; i < bytes.Length; i++) diff --git a/tests/Dotsider.Tests/NativeDisasmOracleCoverageTests.cs b/tests/Dotsider.Tests/NativeDisasmOracleCoverageTests.cs index 9d4dcc59..ae329f63 100644 --- a/tests/Dotsider.Tests/NativeDisasmOracleCoverageTests.cs +++ b/tests/Dotsider.Tests/NativeDisasmOracleCoverageTests.cs @@ -8,6 +8,7 @@ namespace Dotsider.Tests; /// The oracle metadata records the runtime source files and capture utility used for review. /// This keeps unsupported public SDK RID packs from becoming untested decoder paths. /// +[TestClass] public sealed class NativeDisasmOracleCoverageTests { /// @@ -15,10 +16,10 @@ public sealed class NativeDisasmOracleCoverageTests /// The fixture must point back to the file-based oracle utility and runtime ground-truth files. /// This is the explicit fallback when dotnet publish cannot produce the image locally. /// - [Theory] - [InlineData("riscv64", "runtime-smoke.json", NativeArchitecture.RiscV64)] - [InlineData("loongarch64", "runtime-smoke.json", NativeArchitecture.LoongArch64)] - [InlineData("wasm32", "runtime-smoke.json", NativeArchitecture.Wasm32)] + [TestMethod] + [DataRow("riscv64", "runtime-smoke.json", NativeArchitecture.RiscV64)] + [DataRow("loongarch64", "runtime-smoke.json", NativeArchitecture.LoongArch64)] + [DataRow("wasm32", "runtime-smoke.json", NativeArchitecture.Wasm32)] public void RuntimeOracleFixture_RecordsCaptureScriptAndRuntimeSources( string directory, string fileName, NativeArchitecture expectedArchitecture) { @@ -26,20 +27,20 @@ public void RuntimeOracleFixture_RecordsCaptureScriptAndRuntimeSources( var fixturePath = Path.Combine( root, "tests", "Dotsider.Tests", "Fixtures", "Disasm", directory, fileName); - Assert.True(File.Exists(fixturePath), fixturePath); + Assert.IsTrue(File.Exists(fixturePath), fixturePath); using var document = JsonDocument.Parse(File.ReadAllText(fixturePath)); var fixture = document.RootElement; var architecture = Enum.Parse(fixture.GetProperty("architecture").GetString()!); - Assert.Equal(expectedArchitecture, architecture); - Assert.Equal("scripts/Capture-DisasmOracle.cs", fixture.GetProperty("oracle").GetProperty("script").GetString()); - Assert.True(File.Exists(Path.Combine(root, "scripts", "Capture-DisasmOracle.cs"))); + Assert.AreEqual(expectedArchitecture, architecture); + Assert.AreEqual("scripts/Capture-DisasmOracle.cs", fixture.GetProperty("oracle").GetProperty("script").GetString()); + Assert.IsTrue(File.Exists(Path.Combine(root, "scripts", "Capture-DisasmOracle.cs"))); var runtimeFiles = fixture.GetProperty("runtimeFiles").EnumerateArray().ToArray(); - Assert.NotEmpty(runtimeFiles); - Assert.All(runtimeFiles, file => - Assert.False(string.IsNullOrWhiteSpace(file.GetString()))); + Assert.IsNotEmpty(runtimeFiles); + TestAssert.All(runtimeFiles, file => + Assert.IsFalse(string.IsNullOrWhiteSpace(file.GetString()))); } private static string FindRepositoryRoot() diff --git a/tests/Dotsider.Tests/NativeDisassembleSymbolTests.cs b/tests/Dotsider.Tests/NativeDisassembleSymbolTests.cs index 47cf967c..e075f6a9 100644 --- a/tests/Dotsider.Tests/NativeDisassembleSymbolTests.cs +++ b/tests/Dotsider.Tests/NativeDisassembleSymbolTests.cs @@ -9,41 +9,45 @@ namespace Dotsider.Tests; /// slices a recovered symbol's bytes, decodes them for the real architecture, resolves targets, and /// renders a header with the symbol name — proven over a managed function of the real AOT sample. /// -[Collection("SampleAssemblies")] -public class NativeDisassembleSymbolTests(SampleAssemblyFixture samples) +[TestClass] +public class NativeDisassembleSymbolTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// Verifies a recovered managed function disassembles to a named, non-empty listing. - [Fact(Timeout = 120_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DisassembleSymbol_ManagedFunction_RendersNamedListing() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null || !File.Exists(samples.NativeAotConsoleExe), + TestSkip.When(Samples.NativeAotConsoleExe is null || !File.Exists(Samples.NativeAotConsoleExe), "NativeAOT publish did not run on this leg."); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var symbols = analyzer.NativeSymbols; - Assert.NotNull(symbols); + Assert.IsNotNull(symbols); var fn = symbols!.Symbols.FirstOrDefault(s => s.Kind == NativeSymbolKind.Function && s.ManagedName is not null && s.FileOffset is not null && s.Size > 0); - Assert.NotNull(fn); + Assert.IsNotNull(fn); var result = NativeDisassembler.DisassembleSymbol(analyzer, fn!); - Assert.NotNull(result); + Assert.IsNotNull(result); var (text, instructions, headerLineCount) = result!.Value; Assert.Contains(fn!.ManagedName!, text); - Assert.NotEmpty(instructions); - Assert.True(headerLineCount >= 1); - Assert.All(instructions, i => Assert.False(i.IsFallback)); - Assert.Equal(fn.Size, instructions.Sum(i => i.Length)); + Assert.IsNotEmpty(instructions); + Assert.IsGreaterThanOrEqualTo(1, headerLineCount); + TestAssert.All(instructions, i => Assert.IsFalse(i.IsFallback)); + Assert.AreEqual(fn.Size, instructions.Sum(i => i.Length)); } /// Verifies a managed (non-native) binary yields no native symbols to disassemble. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DisassembleSymbol_ManagedBinary_HasNoNativeSymbols() { - using var analyzer = new AssemblyAnalyzer(samples.HelloWorldDll); - Assert.True(analyzer.NativeSymbols is null || analyzer.NativeSymbols.Symbols.Count == 0); + using var analyzer = new AssemblyAnalyzer(Samples.HelloWorldDll); + Assert.IsTrue(analyzer.NativeSymbols is null || analyzer.NativeSymbols.Symbols.Count == 0); } /// @@ -51,30 +55,29 @@ public void DisassembleSymbol_ManagedBinary_HasNoNativeSymbols() /// (binary compatibility) and that the new resolver overload renders correlation-aware /// target names when a call/branch target resolves. /// - [Fact(Timeout = 120_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DisassembleSymbol_ResolverOverload_PinsBinaryCompatAndResolvesNames() { var twoArg = typeof(NativeDisassembler).GetMethod( nameof(NativeDisassembler.DisassembleSymbol), [typeof(AssemblyAnalyzer), typeof(NativeSymbol)]); - Assert.NotNull(twoArg); + Assert.IsNotNull(twoArg); - Assert.SkipWhen(samples.NativeAotConsoleExe is null || !File.Exists(samples.NativeAotConsoleExe), + TestSkip.When(Samples.NativeAotConsoleExe is null || !File.Exists(Samples.NativeAotConsoleExe), "NativeAOT publish did not run on this leg."); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var info = analyzer.NativeSymbols!; var caller = info.Symbols.FirstOrDefault(s => s.Kind == NativeSymbolKind.Function && s.FileOffset is not null && s.Size > 32); - Assert.SkipWhen(caller is null, "no disassemblable function symbol"); + TestSkip.When(caller is null, "no disassemblable function symbol"); // Rename every recognized target to a sentinel; if a target resolves, the sentinel // wins over the recovered-metadata name. var result = NativeDisassembler.DisassembleSymbol( analyzer, caller!, _ => "SENTINEL_MANAGED_NAME"); - Assert.NotNull(result); - Assert.Equal( - NativeDisassembler.DisassembleSymbol(analyzer, caller!)!.Value.Instructions.Count, - result!.Value.Instructions.Count); + Assert.IsNotNull(result); + Assert.HasCount(NativeDisassembler.DisassembleSymbol(analyzer, caller!)!.Value.Instructions.Count, result!.Value.Instructions); } } diff --git a/tests/Dotsider.Tests/NativeDisassemblerTests.cs b/tests/Dotsider.Tests/NativeDisassemblerTests.cs index 7d15ec92..3cf77cc0 100644 --- a/tests/Dotsider.Tests/NativeDisassemblerTests.cs +++ b/tests/Dotsider.Tests/NativeDisassemblerTests.cs @@ -9,27 +9,30 @@ namespace Dotsider.Tests; /// and spans the decoration providers rely on. The real decoders are /// exercised in the per-family suites; here the wiring is proven. /// +[TestClass] public class NativeDisassemblerTests { /// Verifies undefined x64 opcodes fall back to one .byte each and never desync. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Disassemble_X64Undefined_EmitsExactWidthBytes() { // 0x06 (push es) and 0x0E (push cs) are both #UD in 64-bit → one-byte fallbacks. byte[] code = [0x06, 0x0E]; var insns = NativeDisassembler.Disassemble(code, 0x1000, NativeArchitecture.X64); - Assert.Equal(2, insns.Count); - Assert.All(insns, i => Assert.True(i.IsFallback)); - Assert.All(insns, i => Assert.Equal(".byte", i.Mnemonic)); - Assert.All(insns, i => Assert.Equal(1, i.Length)); - Assert.Equal(0x1000UL, insns[0].Address); - Assert.Equal(0x1001UL, insns[1].Address); - Assert.Equal(NativeInstructionCategory.Unknown, insns[0].Category); + Assert.HasCount(2, insns); + TestAssert.All(insns, i => Assert.IsTrue(i.IsFallback)); + TestAssert.All(insns, i => Assert.AreEqual(".byte", i.Mnemonic)); + TestAssert.All(insns, i => Assert.AreEqual(1, i.Length)); + Assert.AreEqual(0x1000UL, insns[0].Address); + Assert.AreEqual(0x1001UL, insns[1].Address); + Assert.AreEqual(NativeInstructionCategory.Unknown, insns[0].Category); } /// Verifies unallocated arm64 words fall back to one 32-bit .word each. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Disassemble_Arm64Unknown_EmitsWords() { // bits[28:25]=0001 is an unallocated major class (no decode group). 0x00000000 is NOT used — @@ -37,32 +40,34 @@ public void Disassemble_Arm64Unknown_EmitsWords() byte[] code = [0x02, 0x00, 0x00, 0x02, 0x03, 0x00, 0x00, 0x02]; var insns = NativeDisassembler.Disassemble(code, 0x4000, NativeArchitecture.Arm64); - Assert.Equal(2, insns.Count); - Assert.All(insns, i => Assert.Equal(".word", i.Mnemonic)); - Assert.All(insns, i => Assert.Equal(4, i.Length)); - Assert.Equal("0x02000002", insns[0].OperandText); - Assert.Equal(0x4004UL, insns[1].Address); + Assert.HasCount(2, insns); + TestAssert.All(insns, i => Assert.AreEqual(".word", i.Mnemonic)); + TestAssert.All(insns, i => Assert.AreEqual(4, i.Length)); + Assert.AreEqual("0x02000002", insns[0].OperandText); + Assert.AreEqual(0x4004UL, insns[1].Address); } /// Verifies a truncated arm64 tail falls to a byte rather than reading past the end. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Disassemble_Arm64TruncatedTail_FallsToByte() { // 0x02000002 is an unallocated word (bits[28:25]=0001); the trailing 0xAA is a truncated tail. byte[] code = [0x02, 0x00, 0x00, 0x02, 0xAA]; var insns = NativeDisassembler.Disassemble(code, 0, NativeArchitecture.Arm64); - Assert.Equal(2, insns.Count); - Assert.Equal(".word", insns[0].Mnemonic); - Assert.Equal(".byte", insns[1].Mnemonic); - Assert.Equal(1, insns[1].Length); + Assert.HasCount(2, insns); + Assert.AreEqual(".word", insns[0].Mnemonic); + Assert.AreEqual(".byte", insns[1].Mnemonic); + Assert.AreEqual(1, insns[1].Length); } /// Verifies empty input yields no instructions. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Disassemble_Empty_ReturnsEmpty() { - Assert.Empty(NativeDisassembler.Disassemble([], 0, NativeArchitecture.X64)); + Assert.IsEmpty(NativeDisassembler.Disassemble([], 0, NativeArchitecture.X64)); } /// @@ -70,44 +75,46 @@ public void Disassemble_Empty_ReturnsEmpty() /// whose spans slice the rendered line back to the exact mnemonic /// and operand text — the invariant the decoration providers depend on. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DisassembleWithText_StampsDisplayLineAndAccurateLayoutSpans() { byte[] code = [0x90, 0xCC]; var (text, insns, headerCount) = NativeDisassembler.DisassembleWithText( code, 0x2000, NativeArchitecture.X64, header: "// Function: X\n// Size: 2"); - Assert.Equal(3, headerCount); // two header lines + a blank + Assert.AreEqual(3, headerCount); // two header lines + a blank var lines = text.Split('\n'); - Assert.Equal("// Function: X", lines[0]); - Assert.Equal("", lines[2]); + Assert.AreEqual("// Function: X", lines[0]); + Assert.AreEqual("", lines[2]); - Assert.All(insns, i => Assert.NotNull(i.DisplayLine)); + TestAssert.All(insns, i => Assert.IsNotNull(i.DisplayLine)); foreach (var insn in insns) { var line = lines[insn.DisplayLine!.Value - 1]; var layout = insn.Layout!.Value; - Assert.Equal(insn.Mnemonic, line.Substring(layout.MnemonicStart, layout.MnemonicLength)); + Assert.AreEqual(insn.Mnemonic, line.Substring(layout.MnemonicStart, layout.MnemonicLength)); if (layout.OperandsStart >= 0) - Assert.Equal(insn.OperandText, line.Substring(layout.OperandsStart, layout.OperandsLength)); + Assert.AreEqual(insn.OperandText, line.Substring(layout.OperandsStart, layout.OperandsLength)); Assert.StartsWith($"0x{insn.Address:x}:", line); } } /// Verifies the composer emits the exact bytes for the primitive and encoding helpers. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void CodeBlob_ComposesExactBytes() { var blob = new CodeBlob().Rex(w: true).U8(0x01).ModRM(3, 0, 1); - Assert.Equal([0x48, 0x01, 0xC1], blob.ToArray()); + Assert.AreSequenceEqual(new byte[] { 0x48, 0x01, 0xC1 }, blob.ToArray()); // 3-byte VEX for map 0F38, W0, no vvvv, L=0, pp=66 → C4 E2 79 var vex = new CodeBlob().Vex3(r: false, x: false, b: false, map: 2, w: false, vvvv: 0, l: 0, pp: 1).U8(0xDC); var bytes = vex.ToArray(); - Assert.Equal(0xC4, bytes[0]); - Assert.Equal(0xE2, bytes[1]); // ~R ~X ~B mmmmm = 111 00010 - Assert.Equal(0x79, bytes[2]); // W ~vvvv L pp = 0 1111 0 01 + Assert.AreEqual(0xC4, bytes[0]); + Assert.AreEqual(0xE2, bytes[1]); // ~R ~X ~B mmmmm = 111 00010 + Assert.AreEqual(0x79, bytes[2]); // W ~vvvv L pp = 0 1111 0 01 - Assert.Equal([0x00, 0x01, 0x02, 0x03], new CodeBlob().Word(0x03020100).ToArray()); + Assert.AreSequenceEqual(new byte[] { 0x00, 0x01, 0x02, 0x03 }, new CodeBlob().Word(0x03020100).ToArray()); } } diff --git a/tests/Dotsider.Tests/NativeIlInspectorRenderTests.cs b/tests/Dotsider.Tests/NativeIlInspectorRenderTests.cs index 832bd66b..b76c07d2 100644 --- a/tests/Dotsider.Tests/NativeIlInspectorRenderTests.cs +++ b/tests/Dotsider.Tests/NativeIlInspectorRenderTests.cs @@ -12,9 +12,11 @@ namespace Dotsider.Tests; /// IL Inspector, and selecting a function must render its disassembly without an unhandled /// exception (which would crash the app and leave the terminal in a bad state). /// -[Collection("SampleAssemblies")] -public sealed class NativeIlInspectorRenderTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public sealed class NativeIlInspectorRenderTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bApp? _hex1bApp; private Hex1bTerminal? _terminal; private Hex1bAppWorkloadAdapter? _workload; @@ -22,20 +24,21 @@ public sealed class NativeIlInspectorRenderTests(SampleAssemblyFixture samples) private CancellationTokenSource? _cts; /// Opens a Native AOT binary, enters the IL Inspector, selects a function, and asserts it renders without faulting. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task NativeAot_SelectFunction_RendersDisassembly() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null || !File.Exists(samples.NativeAotConsoleExe), + TestSkip.When(Samples.NativeAotConsoleExe is null || !File.Exists(Samples.NativeAotConsoleExe), "NativeAOT publish did not run on this leg."); - _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + _cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); _workload = new Hex1bAppWorkloadAdapter(); _terminal = Hex1bTerminal.CreateBuilder().WithWorkload(_workload).WithHeadless().WithDimensions(120, 30).Build(); DotsiderApp? app = null; _hex1bApp = new Hex1bApp( ctx => { - _state ??= new DotsiderState(_hex1bApp!, samples.NativeAotConsoleExe!); + _state ??= new DotsiderState(_hex1bApp!, Samples.NativeAotConsoleExe!); app ??= new DotsiderApp(_state); return Task.FromResult(app.Build(ctx)); }, @@ -64,24 +67,25 @@ public async Task NativeAot_SelectFunction_RendersDisassembly() .Build() .ApplyAsync(_terminal, _cts.Token); - Assert.False(runTask.IsFaulted, runTask.Exception?.ToString()); + Assert.IsFalse(runTask.IsFaulted, runTask.Exception?.ToString()); } /// With a native function selected, the hints bar must offer go-to-definition and the native focus/hex hints — the same affordances managed mode shows, gated on the native symbol. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task NativeAot_SelectFunction_ShowsGoToDefHint() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null || !File.Exists(samples.NativeAotConsoleExe), + TestSkip.When(Samples.NativeAotConsoleExe is null || !File.Exists(Samples.NativeAotConsoleExe), "NativeAOT publish did not run on this leg."); - _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + _cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); _workload = new Hex1bAppWorkloadAdapter(); _terminal = Hex1bTerminal.CreateBuilder().WithWorkload(_workload).WithHeadless().WithDimensions(160, 30).Build(); DotsiderApp? app = null; _hex1bApp = new Hex1bApp( ctx => { - _state ??= new DotsiderState(_hex1bApp!, samples.NativeAotConsoleExe!); + _state ??= new DotsiderState(_hex1bApp!, Samples.NativeAotConsoleExe!); app ??= new DotsiderApp(_state); return Task.FromResult(app.Build(ctx)); }, @@ -109,24 +113,25 @@ public async Task NativeAot_SelectFunction_ShowsGoToDefHint() .Build() .ApplyAsync(_terminal, _cts.Token); - Assert.False(runTask.IsFaulted, runTask.Exception?.ToString()); + Assert.IsFalse(runTask.IsFaulted, runTask.Exception?.ToString()); } /// Navigating to another symbol and pressing Esc must restore the originating symbol AND its exact cursor offset — the same cursor/scroll preservation managed mode gives via IlBackEntry. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task NativeAot_GoToDefThenBack_RestoresCursor() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null || !File.Exists(samples.NativeAotConsoleExe), + TestSkip.When(Samples.NativeAotConsoleExe is null || !File.Exists(Samples.NativeAotConsoleExe), "NativeAOT publish did not run on this leg."); - _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + _cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); _workload = new Hex1bAppWorkloadAdapter(); _terminal = Hex1bTerminal.CreateBuilder().WithWorkload(_workload).WithHeadless().WithDimensions(160, 40).Build(); DotsiderApp? app = null; _hex1bApp = new Hex1bApp( ctx => { - _state ??= new DotsiderState(_hex1bApp!, samples.NativeAotConsoleExe!); + _state ??= new DotsiderState(_hex1bApp!, Samples.NativeAotConsoleExe!); app ??= new DotsiderApp(_state); return Task.FromResult(app.Build(ctx)); }, @@ -167,7 +172,7 @@ public async Task NativeAot_GoToDefThenBack_RestoresCursor() .WaitUntil(s => s.ContainsText($"0x{target.VirtualAddress:x}:"), TimeSpan.FromSeconds(10)) .Build() .ApplyAsync(_terminal, _cts.Token); - Assert.Single(_state.IlNativeBackStack); + Assert.ContainsSingle(_state.IlNativeBackStack); _state.RestoreFromNativeBackEntry(_state.IlNativeBackStack.Pop()); await new Hex1bTerminalInputSequenceBuilder() @@ -175,9 +180,9 @@ public async Task NativeAot_GoToDefThenBack_RestoresCursor() .Build() .ApplyAsync(_terminal, _cts.Token); - Assert.False(runTask.IsFaulted, runTask.Exception?.ToString()); - Assert.Equal(origin.VirtualAddress, _state.IlSelectedNativeSymbol!.VirtualAddress); - Assert.Equal(recordedOffset, _state.IlEditorState.Cursor.Position.Value); + Assert.IsFalse(runTask.IsFaulted, runTask.Exception?.ToString()); + Assert.AreEqual(origin.VirtualAddress, _state.IlSelectedNativeSymbol!.VirtualAddress); + Assert.AreEqual(recordedOffset, _state.IlEditorState.Cursor.Position.Value); } /// diff --git a/tests/Dotsider.Tests/NativeIlTreeTests.cs b/tests/Dotsider.Tests/NativeIlTreeTests.cs index a42d9b93..0239416c 100644 --- a/tests/Dotsider.Tests/NativeIlTreeTests.cs +++ b/tests/Dotsider.Tests/NativeIlTreeTests.cs @@ -9,9 +9,11 @@ namespace Dotsider.Tests; /// namespace → type → function with the symbol carried on the method rows, so the same tree widget /// drives native disassembly. /// -[Collection("SampleAssemblies")] -public sealed class NativeIlTreeTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public sealed class NativeIlTreeTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bApp? _app; private Hex1bTerminal? _terminal; private Hex1bAppWorkloadAdapter? _workload; @@ -31,19 +33,20 @@ private Hex1bApp CreateApp() } /// Verifies the native tree buckets symbols and carries the symbol on the leaf rows. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void BuildNativeTreeRows_NativeAot_BucketsSymbols() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null || !File.Exists(samples.NativeAotConsoleExe), + TestSkip.When(Samples.NativeAotConsoleExe is null || !File.Exists(Samples.NativeAotConsoleExe), "NativeAOT publish did not run on this leg."); var app = CreateApp(); - using var state = new DotsiderState(app, samples.NativeAotConsoleExe!); + using var state = new DotsiderState(app, Samples.NativeAotConsoleExe!); var rows = IlInspectorView.BuildNativeTreeRows(state); - Assert.NotEmpty(rows); - Assert.Contains(rows, r => r.Kind == IlTreeRowKind.Namespace); + Assert.IsNotEmpty(rows); + Assert.Contains(r => r.Kind == IlTreeRowKind.Namespace, rows); // Expand every namespace, then every type, so the leaf function rows appear (each level is // only emitted once its parent is expanded). @@ -54,17 +57,18 @@ public void BuildNativeTreeRows_NativeAot_BucketsSymbols() } var expanded = IlInspectorView.BuildNativeTreeRows(state); - Assert.Contains(expanded, r => r.Kind == IlTreeRowKind.Method && r.Symbol is not null); + Assert.Contains(r => r.Kind == IlTreeRowKind.Method && r.Symbol is not null, expanded); } /// Verifies a managed binary produces an empty native tree. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void BuildNativeTreeRows_Managed_IsEmpty() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.HelloWorldDll); + using var state = new DotsiderState(app, Samples.HelloWorldDll); - Assert.Empty(IlInspectorView.BuildNativeTreeRows(state)); + Assert.IsEmpty(IlInspectorView.BuildNativeTreeRows(state)); } /// diff --git a/tests/Dotsider.Tests/NativeImportResolverTests.cs b/tests/Dotsider.Tests/NativeImportResolverTests.cs index b230931b..f03c01a7 100644 --- a/tests/Dotsider.Tests/NativeImportResolverTests.cs +++ b/tests/Dotsider.Tests/NativeImportResolverTests.cs @@ -12,27 +12,30 @@ namespace Dotsider.Tests; /// indirect call or PLT/stub jump resolves to the import and composes into /// . /// -[Collection("SampleAssemblies")] -public class NativeImportResolverTests(SampleAssemblyFixture samples) +[TestClass] +public class NativeImportResolverTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// Verifies the resolver reads the AOT binary's imports and names an IAT slot. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_NativeAot_MapsImportSlots() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null || !File.Exists(samples.NativeAotConsoleExe), + TestSkip.When(Samples.NativeAotConsoleExe is null || !File.Exists(Samples.NativeAotConsoleExe), "NativeAOT publish did not run on this leg."); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var resolver = NativeImportResolver.Build(analyzer.RawBytes); - Assert.NotNull(resolver); // a NativeAOT exe imports a handful of OS APIs - Assert.NotEmpty(resolver.Slots); + Assert.IsNotNull(resolver); // a NativeAOT exe imports a handful of OS APIs + Assert.IsNotEmpty(resolver.Slots); // Each mapped slot round-trips through TryResolve to its imported name. var (slotVa, name) = resolver.Slots.First(); - Assert.True(resolver.TryResolve(slotVa, out var import)); - Assert.Equal(name, import.Name); - Assert.False(string.IsNullOrEmpty(import.Name)); + Assert.IsTrue(resolver.TryResolve(slotVa, out var import)); + Assert.AreEqual(name, import.Name); + Assert.IsFalse(string.IsNullOrEmpty(import.Name)); // The MODULE!Function form is a PE Import Address Table convention; ELF PLT/GOT and Mach-O // stub imports are bare (or leading-underscored) symbol names with no module qualifier. @@ -41,15 +44,16 @@ public void Build_NativeAot_MapsImportSlots() } /// Verifies the import resolver composes into DisassembleSymbol's target naming. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DisassembleSymbol_ComposesImportResolver() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null || !File.Exists(samples.NativeAotConsoleExe), + TestSkip.When(Samples.NativeAotConsoleExe is null || !File.Exists(Samples.NativeAotConsoleExe), "NativeAOT publish did not run on this leg."); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var resolver = NativeImportResolver.Build(analyzer.RawBytes); - Assert.NotNull(resolver); + Assert.IsNotNull(resolver); // A synthetic call [rip+0] whose slot is the first IAT entry names the import. var (slotVa, expected) = resolver.Slots.First(); @@ -58,21 +62,23 @@ public void DisassembleSymbol_ComposesImportResolver() bool Compose(ulong va, out NativeSymbolRef sym) => resolver.TryResolve(va, out sym); var insn = NativeDisassembler.Disassemble(code, callAddress, NativeArchitecture.X64, Compose)[0]; - Assert.Equal(expected, insn.TargetName); + Assert.AreEqual(expected, insn.TargetName); } /// Verifies a binary with no import table yields no resolver. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_NoImports_ReturnsNull() { - Assert.Null(NativeImportResolver.Build(new byte[] { 0x00, 0x01, 0x02, 0x03 })); + Assert.IsNull(NativeImportResolver.Build(new byte[] { 0x00, 0x01, 0x02, 0x03 })); } /// /// A synthetic ELF with a .rela.plt JUMP_SLOT relocation binds its GOT slot to a /// .dynsym symbol, so the resolver names the slot the PLT stub jumps through. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_Elf_MapsPltGotSlotToDynamicSymbol() { const ulong gotSlotVa = 0x2018; @@ -80,19 +86,20 @@ public void Build_Elf_MapsPltGotSlotToDynamicSymbol() var resolver = NativeImportResolver.Build(image); - Assert.NotNull(resolver); - Assert.True(resolver.TryResolve(gotSlotVa, out var import)); - Assert.Equal("malloc", import.Name); + Assert.IsNotNull(resolver); + Assert.IsTrue(resolver.TryResolve(gotSlotVa, out var import)); + Assert.AreEqual("malloc", import.Name); } /// Verifies the ELF resolver composes into DisassembleSymbol: a PLT stub's inner jmp through the GOT slot names the import. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DisassembleSymbol_ComposesElfImportResolver() { const ulong stubVa = 0x1020; const ulong gotSlotVa = 0x2018; var resolver = NativeImportResolver.Build(BuildSyntheticElf(gotSlotVa, "malloc")); - Assert.NotNull(resolver); + Assert.IsNotNull(resolver); // jmp [rip+disp]; next-IP (stub + 6) + disp == the GOT slot. var disp = (uint)(gotSlotVa - (stubVa + 6)); @@ -100,7 +107,7 @@ public void DisassembleSymbol_ComposesElfImportResolver() bool Compose(ulong va, out NativeSymbolRef sym) => resolver.TryResolve(va, out sym); var insn = NativeDisassembler.Disassemble(stub, stubVa, NativeArchitecture.X64, Compose)[0]; - Assert.Equal("malloc", insn.TargetName); + Assert.AreEqual("malloc", insn.TargetName); } /// @@ -134,25 +141,27 @@ private static byte[] BuildSyntheticElf(ulong gotSlotVa, string importName) /// A synthetic Mach-O __stubs section resolves each stub through the indirect symbol table /// to its imported symbol, so the stub's virtual address names the import. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_MachO_MapsStubToImportedSymbol() { const ulong stubVa = 0x1000; var resolver = NativeImportResolver.Build(BuildSyntheticMachO(stubVa, "_malloc")); - Assert.NotNull(resolver); - Assert.True(resolver.TryResolve(stubVa, out var import)); - Assert.Equal("_malloc", import.Name); + Assert.IsNotNull(resolver); + Assert.IsTrue(resolver.TryResolve(stubVa, out var import)); + Assert.AreEqual("_malloc", import.Name); } /// Verifies the Mach-O resolver composes into DisassembleSymbol: a direct call to the stub names the import. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DisassembleSymbol_ComposesMachOImportResolver() { const ulong stubVa = 0x1000; const ulong callVa = 0x800; var resolver = NativeImportResolver.Build(BuildSyntheticMachO(stubVa, "_malloc")); - Assert.NotNull(resolver); + Assert.IsNotNull(resolver); // call rel32; next-IP (call + 5) + rel == the stub. var rel = (uint)(stubVa - (callVa + 5)); @@ -160,7 +169,7 @@ public void DisassembleSymbol_ComposesMachOImportResolver() bool Compose(ulong va, out NativeSymbolRef sym) => resolver.TryResolve(va, out sym); var insn = NativeDisassembler.Disassemble(call, callVa, NativeArchitecture.X64, Compose)[0]; - Assert.Equal("_malloc", insn.TargetName); + Assert.AreEqual("_malloc", insn.TargetName); } /// diff --git a/tests/Dotsider.Tests/NativeMetadataReaderTests.cs b/tests/Dotsider.Tests/NativeMetadataReaderTests.cs index 8611c6da..dbcb2e06 100644 --- a/tests/Dotsider.Tests/NativeMetadataReaderTests.cs +++ b/tests/Dotsider.Tests/NativeMetadataReaderTests.cs @@ -7,25 +7,28 @@ namespace Dotsider.Tests; /// . The embedded metadata is file-backed on /// every platform, so these run on all CI runners regardless of image format. /// -[Collection("SampleAssemblies")] -public class NativeMetadataReaderTests(SampleAssemblyFixture samples) +[TestClass] +public class NativeMetadataReaderTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// Verifies the sample's own type and its entry-point method are recovered from the /// embedded metadata. Top-level statements compile to a Program type whose /// entry point is named <Main>$. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RecoveredTypes_NativeAotExe_NamesOwnProgramType() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var types = analyzer.RecoveredTypes; - Assert.NotEmpty(types); - var program = Assert.Single(types, t => t.FullName == "Program"); + Assert.IsNotEmpty(types); + var program = Assert.ContainsSingle(t => t.FullName == "Program", types); Assert.Contains("
$", program.MethodNames); } @@ -33,37 +36,39 @@ public void RecoveredTypes_NativeAotExe_NamesOwnProgramType() /// Verifies framework types are recovered with namespace qualification, nested types /// use +, and every recovered name is well-formed. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RecoveredTypes_NativeAotExe_IncludesNamespaceQualifiedFrameworkTypes() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var types = analyzer.RecoveredTypes; - var systemObject = Assert.Single(types, t => t.FullName == "System.Object"); + var systemObject = Assert.ContainsSingle(t => t.FullName == "System.Object", types); Assert.Contains(".ctor", systemObject.MethodNames); - Assert.Contains(types, t => t.FullName == "System.String"); - Assert.Contains(types, t => t.FullName.Contains('+', StringComparison.Ordinal)); // a nested type + Assert.Contains(t => t.FullName == "System.String", types); + Assert.Contains(t => t.FullName.Contains('+', StringComparison.Ordinal), types); // a nested type // Every recovered name is non-empty and every method name is non-empty. - Assert.All(types, t => + TestAssert.All(types, t => { - Assert.False(string.IsNullOrEmpty(t.FullName)); - Assert.All(t.MethodNames, m => Assert.False(string.IsNullOrEmpty(m))); + Assert.IsFalse(string.IsNullOrEmpty(t.FullName)); + TestAssert.All(t.MethodNames, m => Assert.IsFalse(string.IsNullOrEmpty(m))); }); } /// /// Verifies a managed assembly recovers no NativeFormat types (it has no AOT metadata). /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RecoveredTypes_ManagedDll_Empty() { - using var analyzer = new AssemblyAnalyzer(samples.RichLibraryDll); + using var analyzer = new AssemblyAnalyzer(Samples.RichLibraryDll); - Assert.Empty(analyzer.RecoveredTypes); + Assert.IsEmpty(analyzer.RecoveredTypes); } /// @@ -71,35 +76,37 @@ public void RecoveredTypes_ManagedDll_Empty() /// resolve to System.Private.CoreLib and the app's own type to the app assembly — /// which native symbol demangling joins against. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RecoveredTypes_NativeAotExe_CarryAssemblyScopeNames() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var types = analyzer.RecoveredTypes; - var systemObject = Assert.Single(types, t => t.FullName == "System.Object"); - Assert.Equal("System.Private.CoreLib", systemObject.AssemblyName); - var program = Assert.Single(types, t => t.FullName == "Program"); - Assert.Equal("NativeAotConsole", program.AssemblyName); + var systemObject = Assert.ContainsSingle(t => t.FullName == "System.Object", types); + Assert.AreEqual("System.Private.CoreLib", systemObject.AssemblyName); + var program = Assert.ContainsSingle(t => t.FullName == "Program", types); + Assert.AreEqual("NativeAotConsole", program.AssemblyName); } /// /// Verifies the explicit two-value deconstruction still compiles and yields the type name /// and its methods, unaffected by the added assembly-scope parameter. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RecoveredType_TwoValueDeconstruction_Preserved() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var (fullName, methodNames) = analyzer.RecoveredTypes.First(t => t.FullName == "Program"); - Assert.Equal("Program", fullName); + Assert.AreEqual("Program", fullName); Assert.Contains("
$", methodNames); } } diff --git a/tests/Dotsider.Tests/NativeNavigationTests.cs b/tests/Dotsider.Tests/NativeNavigationTests.cs index 218fb401..78ae518e 100644 --- a/tests/Dotsider.Tests/NativeNavigationTests.cs +++ b/tests/Dotsider.Tests/NativeNavigationTests.cs @@ -8,9 +8,11 @@ namespace Dotsider.Tests; /// Tests for native IL-inspector navigation: go-to-definition selects the target symbol and pushes /// the previous view onto the native back stack, and Esc restores it. /// -[Collection("SampleAssemblies")] -public sealed class NativeNavigationTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public sealed class NativeNavigationTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bApp? _app; private Hex1bTerminal? _terminal; private Hex1bAppWorkloadAdapter? _workload; @@ -25,19 +27,20 @@ private Hex1bApp CreateApp() } /// Verifies go-to-symbol pushes the back stack and Esc-restore returns to the origin. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NavigateToNativeSymbol_PushesBackStack_AndRestores() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null || !File.Exists(samples.NativeAotConsoleExe), + TestSkip.When(Samples.NativeAotConsoleExe is null || !File.Exists(Samples.NativeAotConsoleExe), "NativeAOT publish did not run on this leg."); var app = CreateApp(); - using var state = new DotsiderState(app, samples.NativeAotConsoleExe!); + using var state = new DotsiderState(app, Samples.NativeAotConsoleExe!); var functions = state.Analyzer.NativeSymbols!.Symbols .Where(s => s.Kind == Core.Analysis.Models.NativeSymbolKind.Function && s.Size > 0) .Take(2).ToList(); - Assert.Equal(2, functions.Count); + Assert.HasCount(2, functions); // Establish the real precondition a UI navigation has: an editor loaded for the current // symbol with the cursor somewhere in its listing. NavigateToNativeSymbol captures that @@ -50,14 +53,14 @@ public void NavigateToNativeSymbol_PushesBackStack_AndRestores() state.NavigateToNativeSymbol(functions[1]); - Assert.Equal(functions[1].VirtualAddress, state.IlSelectedNativeSymbol!.VirtualAddress); - Assert.Single(state.IlNativeBackStack); + Assert.AreEqual(functions[1].VirtualAddress, state.IlSelectedNativeSymbol!.VirtualAddress); + Assert.ContainsSingle(state.IlNativeBackStack); // Move the cursor away, then restore: the offset must snap back to where the jump departed. state.IlEditorState.SetCursorPosition(new DocumentOffset(0)); state.RestoreFromNativeBackEntry(state.IlNativeBackStack.Pop()); - Assert.Equal(functions[0].VirtualAddress, state.IlSelectedNativeSymbol!.VirtualAddress); - Assert.Equal(recordedOffset, state.IlEditorState.Cursor.Position.Value); + Assert.AreEqual(functions[0].VirtualAddress, state.IlSelectedNativeSymbol!.VirtualAddress); + Assert.AreEqual(recordedOffset, state.IlEditorState.Cursor.Position.Value); } /// diff --git a/tests/Dotsider.Tests/NativePdbReaderTests.cs b/tests/Dotsider.Tests/NativePdbReaderTests.cs index 09703b53..405b35bd 100644 --- a/tests/Dotsider.Tests/NativePdbReaderTests.cs +++ b/tests/Dotsider.Tests/NativePdbReaderTests.cs @@ -9,28 +9,31 @@ namespace Dotsider.Tests; /// sample on Windows. These run on the Windows CI leg, where the symbol file is a .pdb; /// the block-math and container tests in cover the other platforms. /// -[Collection("SampleAssemblies")] -public class NativePdbReaderTests(SampleAssemblyFixture samples) +[TestClass] +public class NativePdbReaderTests { - private bool HasPdb => - samples.NativeAotConsoleSymbols is not null - && samples.NativeAotConsoleSymbols.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase); + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + + private static bool HasPdb => + Samples.NativeAotConsoleSymbols is not null + && Samples.NativeAotConsoleSymbols.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase); /// /// Verifies the PDB's GUID reads through the cheap probe and matches the GUID embedded in the /// executable's RSDS debug directory (the bytes appear verbatim in the image). /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TryReadPdbId_MatchesExeRsdsGuid() { - Assert.SkipWhen(!HasPdb, "native PDB not present on this platform"); + TestSkip.When(!HasPdb, "native PDB not present on this platform"); - Assert.True(NativePdbReader.TryReadPdbId(samples.NativeAotConsoleSymbols!, out var guid, out var age)); - Assert.NotEqual(Guid.Empty, guid); - Assert.True(age > 0); + Assert.IsTrue(NativePdbReader.TryReadPdbId(Samples.NativeAotConsoleSymbols!, out var guid, out var age)); + Assert.AreNotEqual(Guid.Empty, guid); + Assert.IsGreaterThan(0, age); - var exe = File.ReadAllBytes(samples.NativeAotConsoleExe!); - Assert.True(IndexOf(exe, guid.ToByteArray()) >= 0, "PDB GUID not found in the exe RSDS entry"); + var exe = File.ReadAllBytes(Samples.NativeAotConsoleExe!); + Assert.IsGreaterThanOrEqualTo(0, IndexOf(exe, guid.ToByteArray()), "PDB GUID not found in the exe RSDS entry"); } /// @@ -38,36 +41,38 @@ public void TryReadPdbId_MatchesExeRsdsGuid() /// the app's entry point, and that C13 line data attributes at least one function to a source /// file and line. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_FixturePdb_RecoversFunctionsWithLineData() { - Assert.SkipWhen(!HasPdb, "native PDB not present on this platform"); + TestSkip.When(!HasPdb, "native PDB not present on this platform"); - var pdb = File.ReadAllBytes(samples.NativeAotConsoleSymbols!); - var exe = File.ReadAllBytes(samples.NativeAotConsoleExe!); + var pdb = File.ReadAllBytes(Samples.NativeAotConsoleSymbols!); + var exe = File.ReadAllBytes(Samples.NativeAotConsoleExe!); var symbols = NativePdbReader.Read(pdb, exe); - Assert.NotEmpty(symbols); + Assert.IsNotEmpty(symbols); // Addresses resolved: every symbol has a non-zero VA and an RVA. - Assert.All(symbols, s => + TestAssert.All(symbols, s => { - Assert.True(s.VirtualAddress > 0); - Assert.NotNull(s.Rva); + Assert.IsGreaterThan(0UL, s.VirtualAddress); + Assert.IsNotNull(s.Rva); }); // The entry point is present in mangled form. - Assert.Contains(symbols, s => s.Name.Contains("Program___Main__", StringComparison.Ordinal)); + Assert.Contains(s => s.Name.Contains("Program___Main__", StringComparison.Ordinal), symbols); // C13 line data attributed at least one function to a source location. - Assert.Contains(symbols, s => s.SourceFile is not null && s.Line is > 0); + Assert.Contains(s => s.SourceFile is not null && s.Line is > 0, symbols); } /// /// Verifies a non-PDB file returns no symbols rather than throwing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_NotAPdb_ReturnsEmpty() { - Assert.Empty(NativePdbReader.Read([0xDE, 0xAD, 0xBE, 0xEF], new byte[64])); + Assert.IsEmpty(NativePdbReader.Read([0xDE, 0xAD, 0xBE, 0xEF], new byte[64])); } /// @@ -75,30 +80,31 @@ public void Read_NotAPdb_ReturnsEmpty() /// demangled: addresses are unique (no double count), managed names join, and the compiler's /// data symbols (MethodTables) surface as their own kind. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_FixturePdb_MergesDemanglesAndClassifies() { - Assert.SkipWhen(!HasPdb, "native PDB not present on this platform"); + TestSkip.When(!HasPdb, "native PDB not present on this platform"); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var raw = NativePdbReader.Read( - File.ReadAllBytes(samples.NativeAotConsoleSymbols!), analyzer.RawBytes.ToArray()); + File.ReadAllBytes(Samples.NativeAotConsoleSymbols!), analyzer.RawBytes.ToArray()); var demangler = new IlcNameDemangler(analyzer.RecoveredTypes); var info = NativeSymbolReader.Build( raw, demangler, NativeSymbolSource.NativePdb, NativeSymbolStatus.Loaded, - samples.NativeAotConsoleSymbols, null, + Samples.NativeAotConsoleSymbols, null, NativeArchitecture.X64); - Assert.NotEmpty(info.Symbols); + Assert.IsNotEmpty(info.Symbols); // No two symbols share an address after the merge. - Assert.Equal(info.Symbols.Count, info.Symbols.Select(s => s.VirtualAddress).Distinct().Count()); + Assert.HasCount(info.Symbols.Count, info.Symbols.Select(s => s.VirtualAddress).Distinct()); // Managed names joined for some functions. - Assert.Contains(info.Symbols, s => s.IsExactMatch && s.ManagedName is not null); + Assert.Contains(s => s.IsExactMatch && s.ManagedName is not null, info.Symbols); // The compiler's MethodTable data symbols are recovered and classified. - Assert.Contains(info.Symbols, s => s.Kind == NativeSymbolKind.MethodTable); + Assert.Contains(s => s.Kind == NativeSymbolKind.MethodTable, info.Symbols); } diff --git a/tests/Dotsider.Tests/NativeRecordCompatTests.cs b/tests/Dotsider.Tests/NativeRecordCompatTests.cs index 2ea3a0cb..d8900d7e 100644 --- a/tests/Dotsider.Tests/NativeRecordCompatTests.cs +++ b/tests/Dotsider.Tests/NativeRecordCompatTests.cs @@ -10,81 +10,88 @@ namespace Dotsider.Tests; /// adds ; its ten-arg constructor and deconstruction shape /// must remain source-compatible. /// +[TestClass] public class NativeRecordCompatTests { /// Verifies the pre-ManagedNativeHeader ten-argument ClrHeader constructor still works. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ClrHeader_OldConstructor_DefaultsManagedNativeHeader() { var header = new ClrHeader(2, 5, 0x100, 0x200, CorFlags.ILOnly, 0x06000001, 0, 0, 0, 0); - Assert.Equal(default, header.ManagedNativeHeader); - Assert.Equal(0, header.ManagedNativeHeader.Size); + Assert.AreEqual(default, header.ManagedNativeHeader); + Assert.AreEqual(0, header.ManagedNativeHeader.Size); } /// Verifies the old ten-output ClrHeader deconstruction still works. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ClrHeader_OldDeconstruct_YieldsTen() { var header = new ClrHeader(2, 5, 0x100, 0x200, CorFlags.ILOnly, 0x06000001, 0x300, 0x40, 0, 0, new DirectoryEntry(0x500, 0x60)); var (major, minor, metadataRva, metadataSize, flags, entryPoint, resRva, resSize, snRva, snSize) = header; - Assert.Equal(2, major); - Assert.Equal(5, minor); - Assert.Equal(0x100, metadataRva); - Assert.Equal(0x200, metadataSize); - Assert.Equal(CorFlags.ILOnly, flags); - Assert.Equal(0x06000001, entryPoint); - Assert.Equal(0x300, resRva); - Assert.Equal(0x40, resSize); - Assert.Equal(0, snRva); - Assert.Equal(0, snSize); + Assert.AreEqual(2, major); + Assert.AreEqual(5, minor); + Assert.AreEqual(0x100, metadataRva); + Assert.AreEqual(0x200, metadataSize); + Assert.AreEqual(CorFlags.ILOnly, flags); + Assert.AreEqual(0x06000001, entryPoint); + Assert.AreEqual(0x300, resRva); + Assert.AreEqual(0x40, resSize); + Assert.AreEqual(0, snRva); + Assert.AreEqual(0, snSize); } /// Verifies the old five-argument NativeSymbolInfo constructor still works. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeSymbolInfo_OldConstructor_DefaultsNewFields() { var info = new NativeSymbolInfo([], NativeSymbolSource.NativePdb, NativeSymbolStatus.Loaded, "p", "d"); - Assert.Equal(NativeArchitecture.Unknown, info.Architecture); - Assert.Null(info.SourceMap); + Assert.AreEqual(NativeArchitecture.Unknown, info.Architecture); + Assert.IsNull(info.SourceMap); } /// Verifies the old five-output NativeSymbolInfo deconstruction still works. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeSymbolInfo_OldDeconstruct_YieldsFive() { var info = new NativeSymbolInfo([], NativeSymbolSource.NativePdb, NativeSymbolStatus.Loaded, "p", "d", NativeArchitecture.X64, null); var (symbols, source, status, path, diagnostic) = info; - Assert.Empty(symbols); - Assert.Equal(NativeSymbolSource.NativePdb, source); - Assert.Equal(NativeSymbolStatus.Loaded, status); - Assert.Equal("p", path); - Assert.Equal("d", diagnostic); + Assert.IsEmpty(symbols); + Assert.AreEqual(NativeSymbolSource.NativePdb, source); + Assert.AreEqual(NativeSymbolStatus.Loaded, status); + Assert.AreEqual("p", path); + Assert.AreEqual("d", diagnostic); } /// Verifies the old five- and six-argument SizeNode constructors still work. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SizeNode_OldConstructors_DefaultNativeAddress() { var five = new SizeNode("n", "p", 10, SizeNodeKind.Method, []); var six = new SizeNode("n", "p", 10, SizeNodeKind.Method, [], "aot"); - Assert.Null(five.NativeAddress); - Assert.Null(six.NativeAddress); - Assert.Equal("aot", six.AotNodeName); + Assert.IsNull(five.NativeAddress); + Assert.IsNull(six.NativeAddress); + Assert.AreEqual("aot", six.AotNodeName); } /// Verifies the old six-output SizeNode deconstruction still works. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SizeNode_OldDeconstruct_YieldsSix() { var node = new SizeNode("n", "p", 10, SizeNodeKind.Type, [], "aot", 0x1000); var (name, fullPath, size, kind, children, aotNodeName) = node; - Assert.Equal("n", name); - Assert.Equal("p", fullPath); - Assert.Equal(10, size); - Assert.Equal(SizeNodeKind.Type, kind); - Assert.Empty(children); - Assert.Equal("aot", aotNodeName); + Assert.AreEqual("n", name); + Assert.AreEqual("p", fullPath); + Assert.AreEqual(10, size); + Assert.AreEqual(SizeNodeKind.Type, kind); + Assert.IsEmpty(children); + Assert.AreEqual("aot", aotNodeName); } } diff --git a/tests/Dotsider.Tests/NativeResolutionTests.cs b/tests/Dotsider.Tests/NativeResolutionTests.cs index a63d0c4c..94f3973e 100644 --- a/tests/Dotsider.Tests/NativeResolutionTests.cs +++ b/tests/Dotsider.Tests/NativeResolutionTests.cs @@ -8,35 +8,39 @@ namespace Dotsider.Tests; /// instruction, intra-window branch targets synthesized as loc_ labels, and resolved symbols /// rendered as Name or Name+0x.. — the naming that drives go-to-definition and the ; annotations. /// +[TestClass] public class NativeResolutionTests { /// Verifies a RIP-relative reference resolves to next-IP + disp as a Data target. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RipRelative_ComputesAbsoluteDataTarget() { // lea rax, [rip+0x0] at 0x1000, length 7 → target 0x1007. byte[] code = [0x48, 0x8D, 0x05, 0x00, 0x00, 0x00, 0x00]; var insn = NativeDisassembler.Disassemble(code, 0x1000, NativeArchitecture.X64)[0]; - Assert.Equal(0x1007UL, insn.TargetAddress); - Assert.Equal(NativeTargetKind.Data, insn.TargetKind); + Assert.AreEqual(0x1007UL, insn.TargetAddress); + Assert.AreEqual(NativeTargetKind.Data, insn.TargetKind); } /// Verifies an intra-window branch target becomes a synthesized local label. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void IntraWindowBranch_BecomesLocalLabel() { // jmp +2 (0xEB 0x00 lands on the next instruction 0x1002), then two nops. byte[] code = [0xEB, 0x00, 0x90, 0x90]; var insns = NativeDisassembler.Disassemble(code, 0x1000, NativeArchitecture.X64); - Assert.Equal(0x1002UL, insns[0].TargetAddress); - Assert.Equal(NativeTargetKind.LocalLabel, insns[0].TargetKind); - Assert.Equal("loc_1002", insns[0].TargetName); + Assert.AreEqual(0x1002UL, insns[0].TargetAddress); + Assert.AreEqual(NativeTargetKind.LocalLabel, insns[0].TargetKind); + Assert.AreEqual("loc_1002", insns[0].TargetName); } /// Verifies the loc_ label line is emitted above its target in the rendered listing. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DisassembleWithText_EmitsLocalLabelLine() { byte[] code = [0xEB, 0x00, 0x90, 0x90]; @@ -45,7 +49,8 @@ public void DisassembleWithText_EmitsLocalLabelLine() } /// Verifies a call landing inside a resolved symbol renders as Name+0x.. and exactly as Name. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ResolvedCall_RendersNameAndOffset() { // call rel32 to 0x2000, then call rel32 to 0x2005. @@ -64,8 +69,8 @@ static bool Resolver(ulong va, out NativeSymbolRef sym) } var insns = NativeDisassembler.Disassemble(code, 0x1000, NativeArchitecture.X64, Resolver); - Assert.Equal("Foo", insns[0].TargetName); - Assert.Equal("Foo+0x5", insns[1].TargetName); - Assert.Equal(NativeTargetKind.Function, insns[0].TargetKind); + Assert.AreEqual("Foo", insns[0].TargetName); + Assert.AreEqual("Foo+0x5", insns[1].TargetName); + Assert.AreEqual(NativeTargetKind.Function, insns[0].TargetKind); } } diff --git a/tests/Dotsider.Tests/NativeSymbolMergeTests.cs b/tests/Dotsider.Tests/NativeSymbolMergeTests.cs index ae01f83a..5baa7355 100644 --- a/tests/Dotsider.Tests/NativeSymbolMergeTests.cs +++ b/tests/Dotsider.Tests/NativeSymbolMergeTests.cs @@ -8,6 +8,7 @@ namespace Dotsider.Tests; /// (its Build step), driven with synthetic raw symbols so the rules are pinned independently /// of any format reader. /// +[TestClass] public class NativeSymbolMergeTests { private static readonly IlcNameDemangler EmptyDemangler = new([]); @@ -25,37 +26,40 @@ private static NativeSymbolInfo Build(params RawNativeSymbol[] raw) => /// Verifies two records at the same address collapse to one symbol, the richer record's size /// wins, and the other name becomes an alias — so the address is never counted twice. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_SameAddress_MergesToPrimaryWithAlias() { var info = Build( Raw("proc_with_size", 0x1000, 0x40), Raw("public_no_size", 0x1000, 0)); - var symbol = Assert.Single(info.Symbols); - Assert.Equal("proc_with_size", symbol.Name); - Assert.Equal(0x40, symbol.Size); + var symbol = Assert.ContainsSingle(info.Symbols); + Assert.AreEqual("proc_with_size", symbol.Name); + Assert.AreEqual(0x40, symbol.Size); Assert.Contains("public_no_size", symbol.Aliases); } /// /// Verifies an unsized symbol is sized by the distance to the next symbol's address. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_UnsizedSymbol_SizedByNextAddress() { var info = Build( Raw("a", 0x1000, 0), Raw("b", 0x1050, 0)); - Assert.Equal(0x50, info.Symbols[0].Size); + Assert.AreEqual(0x50, info.Symbols[0].Size); } /// /// Verifies the merged set has no two symbols at the same address — the Size Map sums this set, /// so a duplicate would double-count. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_Result_HasUniqueAddresses() { var info = Build( @@ -63,20 +67,21 @@ public void Build_Result_HasUniqueAddresses() Raw("a_alias", 0x1000, 0), Raw("b", 0x1020, 0x10)); - Assert.Equal(info.Symbols.Count, info.Symbols.Select(s => s.VirtualAddress).Distinct().Count()); + Assert.HasCount(info.Symbols.Count, info.Symbols.Select(s => s.VirtualAddress).Distinct()); } /// /// Verifies a boundary record classifies as a boundary with no managed name. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_Boundary_ClassifiesAsBoundary() { var info = Build(Raw("sub_1000", 0x1000, 0x10, isBoundary: true)); - var symbol = Assert.Single(info.Symbols); - Assert.Equal(NativeSymbolKind.Boundary, symbol.Kind); - Assert.Null(symbol.ManagedName); + var symbol = Assert.ContainsSingle(info.Symbols); + Assert.AreEqual(NativeSymbolKind.Boundary, symbol.Kind); + Assert.IsNull(symbol.ManagedName); } /// @@ -84,23 +89,25 @@ public void Build_Boundary_ClassifiesAsBoundary() /// thunks would otherwise inflate the Size Map's data categories — while a recognized ILC /// node at the same footing survives. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_UnrecognizedDataSymbol_Dropped() { var info = Build( Raw("__imp_GetLastError", 0x2000, 0x08, isData: true), Raw("_ZTV6Widget", 0x2010, 0x18, isData: true)); - var symbol = Assert.Single(info.Symbols); - Assert.Equal("_ZTV6Widget", symbol.Name); - Assert.Equal(NativeSymbolKind.MethodTable, symbol.Kind); + var symbol = Assert.ContainsSingle(info.Symbols); + Assert.AreEqual("_ZTV6Widget", symbol.Name); + Assert.AreEqual(NativeSymbolKind.MethodTable, symbol.Kind); } /// /// Verifies a data record joined to a managed name is kept and classified as data rather /// than dropped with the unrecognized ones. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_ManagedJoinedDataSymbol_KeptAsData() { var demangler = new IlcNameDemangler([new RecoveredType("System.Foo", ["Bar"], "System.Private.CoreLib")]); @@ -108,26 +115,27 @@ public void Build_ManagedJoinedDataSymbol_KeptAsData() [Raw("S_P_CoreLib_System_Foo__Bar", 0x2000, 0x10, isData: true)], demangler, NativeSymbolSource.NativePdb, NativeSymbolStatus.Loaded, "x.pdb", null); - var symbol = Assert.Single(info.Symbols); - Assert.Equal(NativeSymbolKind.Data, symbol.Kind); - Assert.Equal("System.Foo.Bar", symbol.ManagedName); + var symbol = Assert.ContainsSingle(info.Symbols); + Assert.AreEqual(NativeSymbolKind.Data, symbol.Kind); + Assert.AreEqual("System.Foo.Bar", symbol.ManagedName); } /// /// Verifies a merged data group whose primary is unrecognized re-fronts a recognized alias /// instead of dropping the address: the node name leads and the old primary becomes an alias. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_UnrecognizedDataPrimary_PromotesRecognizedAlias() { var info = Build( Raw("crt_state", 0x2000, 0x18, isData: true), Raw("_ZTV6Widget", 0x2000, 0, isData: true)); - var symbol = Assert.Single(info.Symbols); - Assert.Equal("_ZTV6Widget", symbol.Name); - Assert.Equal(NativeSymbolKind.MethodTable, symbol.Kind); - Assert.Equal(0x18, symbol.Size); + var symbol = Assert.ContainsSingle(info.Symbols); + Assert.AreEqual("_ZTV6Widget", symbol.Name); + Assert.AreEqual(NativeSymbolKind.MethodTable, symbol.Kind); + Assert.AreEqual(0x18, symbol.Size); Assert.Contains("crt_state", symbol.Aliases); } @@ -135,22 +143,24 @@ public void Build_UnrecognizedDataPrimary_PromotesRecognizedAlias() /// Verifies overlapping extents clip to the next symbol's start so the Size Map never /// counts a byte twice. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_OverlappingRanges_ClipToNextStart() { var info = Build( Raw("a", 0x1000, 0x100), Raw("b", 0x1050, 0x20)); - Assert.Equal(0x50, info.Symbols[0].Size); - Assert.Equal(0x20, info.Symbols[1].Size); + Assert.AreEqual(0x50, info.Symbols[0].Size); + Assert.AreEqual(0x20, info.Symbols[1].Size); } /// /// Verifies an unsized symbol is not sized across a section boundary: the gap to a symbol /// in a different section says nothing about its extent. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_UnsizedSymbol_NotSizedAcrossSections() { var raw = new RawNativeSymbol[] @@ -161,14 +171,15 @@ public void Build_UnsizedSymbol_NotSizedAcrossSections() var info = NativeSymbolReader.Build(raw, EmptyDemangler, NativeSymbolSource.NativePdb, NativeSymbolStatus.Loaded, "x.pdb", null); - Assert.Equal(0, info.Symbols[0].Size); + Assert.AreEqual(0, info.Symbols[0].Size); } /// /// Verifies the demangler is applied to primaries: a symbol matching a recovered type/method /// gets its managed name, and the interval lookup resolves an address inside it. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_AppliesDemanglerAndSupportsIntervalLookup() { var demangler = new IlcNameDemangler([new RecoveredType("System.Foo", ["Bar"], "System.Private.CoreLib")]); @@ -176,24 +187,25 @@ public void Build_AppliesDemanglerAndSupportsIntervalLookup() [Raw("S_P_CoreLib_System_Foo__Bar", 0x3000, 0x30)], demangler, NativeSymbolSource.NativePdb, NativeSymbolStatus.Loaded, "x.pdb", null); - Assert.Equal("System.Foo.Bar", info.Symbols[0].ManagedName); - Assert.True(info.Symbols[0].IsExactMatch); - Assert.True(info.TryFindByAddress(0x3010, out var hit)); - Assert.Equal("S_P_CoreLib_System_Foo__Bar", hit.Name); - Assert.False(info.TryFindByAddress(0x3040, out _)); + Assert.AreEqual("System.Foo.Bar", info.Symbols[0].ManagedName); + Assert.IsTrue(info.Symbols[0].IsExactMatch); + Assert.IsTrue(info.TryFindByAddress(0x3010, out var hit)); + Assert.AreEqual("S_P_CoreLib_System_Foo__Bar", hit.Name); + Assert.IsFalse(info.TryFindByAddress(0x3040, out _)); } /// /// Verifies an empty input carries the status and diagnostic so an empty result is explainable. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Build_Empty_CarriesStatus() { var info = NativeSymbolReader.Build([], EmptyDemangler, NativeSymbolSource.PdataFallback, NativeSymbolStatus.NoSymbolFile, null, "no symbol file beside the binary"); - Assert.Empty(info.Symbols); - Assert.Equal(NativeSymbolStatus.NoSymbolFile, info.Status); - Assert.Equal("no symbol file beside the binary", info.Diagnostic); + Assert.IsEmpty(info.Symbols); + Assert.AreEqual(NativeSymbolStatus.NoSymbolFile, info.Status); + Assert.AreEqual("no symbol file beside the binary", info.Diagnostic); } } diff --git a/tests/Dotsider.Tests/NativeSymbolNameTests.cs b/tests/Dotsider.Tests/NativeSymbolNameTests.cs index a1f6b549..b61ff4a5 100644 --- a/tests/Dotsider.Tests/NativeSymbolNameTests.cs +++ b/tests/Dotsider.Tests/NativeSymbolNameTests.cs @@ -6,20 +6,22 @@ namespace Dotsider.Tests; /// Tests for : splitting a recovered managed name into /// namespace, declaring type, and member — signature-aware and generic-aware. /// +[TestClass] public class NativeSymbolNameTests { /// Verifies namespace/type/member splitting across the common managed-name shapes. - [Theory(Timeout = 30_000)] - [InlineData("System.Text.StringBuilder.Append(char)", "System.Text", "StringBuilder", "Append(char)")] - [InlineData("Program.
$(System.String[])", "", "Program", "
$(System.String[])")] - [InlineData("A.B", "", "A", "B")] - [InlineData("Foo", "", "", "Foo")] - [InlineData("N.T.M", "N", "T", "M")] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow("System.Text.StringBuilder.Append(char)", "System.Text", "StringBuilder", "Append(char)")] + [DataRow("Program.
$(System.String[])", "", "Program", "
$(System.String[])")] + [DataRow("A.B", "", "A", "B")] + [DataRow("Foo", "", "", "Foo")] + [DataRow("N.T.M", "N", "T", "M")] public void Parse_SplitsNamespaceTypeMember(string input, string ns, string type, string member) { var parsed = NativeSymbolName.Parse(input); - Assert.Equal(ns, parsed.Namespace); - Assert.Equal(type, parsed.TypeName); - Assert.Equal(member, parsed.MemberName); + Assert.AreEqual(ns, parsed.Namespace); + Assert.AreEqual(type, parsed.TypeName); + Assert.AreEqual(member, parsed.MemberName); } } diff --git a/tests/Dotsider.Tests/NativeSymbolReaderElfTests.cs b/tests/Dotsider.Tests/NativeSymbolReaderElfTests.cs index 6b6126fd..ee63b9ea 100644 --- a/tests/Dotsider.Tests/NativeSymbolReaderElfTests.cs +++ b/tests/Dotsider.Tests/NativeSymbolReaderElfTests.cs @@ -9,15 +9,17 @@ namespace Dotsider.Tests; /// info, and the .eh_frame fallback chain — plus the real NativeAOT fixture on the Linux /// leg (where its symbol file is a .dbg). /// -[Collection("SampleAssemblies")] -public class NativeSymbolReaderElfTests(SampleAssemblyFixture samples) +[TestClass] +public class NativeSymbolReaderElfTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private static readonly byte[] IdA = [0xDE, 0xAD, 0xBE, 0xEF, 1, 2, 3, 4]; private static readonly byte[] IdB = [0xCA, 0xFE, 0xBA, 0xBE, 5, 6, 7, 8]; - private bool HasDbg => - samples.NativeAotConsoleSymbols is not null - && samples.NativeAotConsoleSymbols.EndsWith(".dbg", StringComparison.OrdinalIgnoreCase); + private static bool HasDbg => + Samples.NativeAotConsoleSymbols is not null + && Samples.NativeAotConsoleSymbols.EndsWith(".dbg", StringComparison.OrdinalIgnoreCase); /// Builds one-function .debug_info/.debug_abbrev blobs (v4, name + low_pc + size). private static (byte[] Info, byte[] Abbrev) MinimalDwarf(string name, ulong lowPc, uint size) @@ -48,7 +50,8 @@ private static string Write(string directory, string name, byte[] bytes) } /// Verifies an unstripped image reads its own DWARF: Loaded, path = the image. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_UnstrippedElf_ReadsOwnDwarf() { var dir = Directory.CreateTempSubdirectory("dotsider-elf-"); @@ -62,15 +65,15 @@ public void Read_UnstrippedElf_ReadsOwnDwarf() var result = NativeSymbolReader.Read(exePath, File.ReadAllBytes(exePath), []); - Assert.Equal(NativeSymbolSource.Dwarf, result.Source); - Assert.Equal(NativeSymbolStatus.Loaded, result.Status); - Assert.Equal(exePath, result.Path); - var symbol = Assert.Single(result.Symbols); - Assert.Equal("frost_main", symbol.Name); - Assert.Equal(0x1010UL, symbol.VirtualAddress); - Assert.Equal(0x40, symbol.Size); - Assert.Equal(".text", symbol.Section); - Assert.NotNull(symbol.FileOffset); + Assert.AreEqual(NativeSymbolSource.Dwarf, result.Source); + Assert.AreEqual(NativeSymbolStatus.Loaded, result.Status); + Assert.AreEqual(exePath, result.Path); + var symbol = Assert.ContainsSingle(result.Symbols); + Assert.AreEqual("frost_main", symbol.Name); + Assert.AreEqual(0x1010UL, symbol.VirtualAddress); + Assert.AreEqual(0x40, symbol.Size); + Assert.AreEqual(".text", symbol.Section); + Assert.IsNotNull(symbol.FileOffset); } finally { @@ -82,7 +85,8 @@ public void Read_UnstrippedElf_ReadsOwnDwarf() /// Verifies a build-id-matched sidecar loads: DWARF functions plus symtab data with exact /// sizes, addresses mapped through the image's sections. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_StrippedWithMatchingSidecar_LoadsDwarfAndSymtabData() { var dir = Directory.CreateTempSubdirectory("dotsider-elf-"); @@ -110,19 +114,19 @@ public void Read_StrippedWithMatchingSidecar_LoadsDwarfAndSymtabData() var result = NativeSymbolReader.Read(exePath, File.ReadAllBytes(exePath), []); - Assert.Equal(NativeSymbolStatus.Loaded, result.Status); - Assert.Equal(NativeSymbolSource.Dwarf, result.Source); + Assert.AreEqual(NativeSymbolStatus.Loaded, result.Status); + Assert.AreEqual(NativeSymbolSource.Dwarf, result.Source); Assert.EndsWith("app.dbg", result.Path); - Assert.Null(result.Diagnostic); + Assert.IsNull(result.Diagnostic); - var function = Assert.Single(result.Symbols, s => s.Kind == NativeSymbolKind.Function); - Assert.Equal("frost_main", function.Name); - Assert.Equal(".text", function.Section); + var function = Assert.ContainsSingle(s => s.Kind == NativeSymbolKind.Function, result.Symbols); + Assert.AreEqual("frost_main", function.Name); + Assert.AreEqual(".text", function.Section); - var data = Assert.Single(result.Symbols, s => s.Kind == NativeSymbolKind.MethodTable); - Assert.Equal("_ZTV6Widget", data.Name); - Assert.Equal(0x18, data.Size); // exact st_size - Assert.Equal(".data", data.Section); + var data = Assert.ContainsSingle(s => s.Kind == NativeSymbolKind.MethodTable, result.Symbols); + Assert.AreEqual("_ZTV6Widget", data.Name); + Assert.AreEqual(0x18, data.Size); // exact st_size + Assert.AreEqual(".data", data.Section); } finally { @@ -131,7 +135,8 @@ public void Read_StrippedWithMatchingSidecar_LoadsDwarfAndSymtabData() } /// Verifies a .gnu_debuglink-named sidecar is found and CRC-validated. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_DebugLinkNamedSidecar_FoundAndCrcValidated() { var dir = Directory.CreateTempSubdirectory("dotsider-elf-"); @@ -150,9 +155,9 @@ public void Read_DebugLinkNamedSidecar_FoundAndCrcValidated() var result = NativeSymbolReader.Read(exePath, File.ReadAllBytes(exePath), []); - Assert.Equal(NativeSymbolStatus.Loaded, result.Status); + Assert.AreEqual(NativeSymbolStatus.Loaded, result.Status); Assert.EndsWith("custom.dbg", result.Path); - Assert.Null(result.Diagnostic); // CRC is a real signal, not a loose match + Assert.IsNull(result.Diagnostic); // CRC is a real signal, not a loose match } finally { @@ -163,7 +168,8 @@ public void Read_DebugLinkNamedSidecar_FoundAndCrcValidated() /// /// Verifies a signal-free image accepts its sidecar loosely, with a diagnostic saying so. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_NoSignals_LooseMatchCarriesDiagnostic() { var dir = Directory.CreateTempSubdirectory("dotsider-elf-"); @@ -178,8 +184,8 @@ public void Read_NoSignals_LooseMatchCarriesDiagnostic() var result = NativeSymbolReader.Read(exePath, File.ReadAllBytes(exePath), []); - Assert.Equal(NativeSymbolStatus.Loaded, result.Status); - Assert.NotNull(result.Diagnostic); + Assert.AreEqual(NativeSymbolStatus.Loaded, result.Status); + Assert.IsNotNull(result.Diagnostic); Assert.Contains("machine and debug info only", result.Diagnostic); } finally @@ -192,7 +198,8 @@ public void Read_NoSignals_LooseMatchCarriesDiagnostic() /// Verifies a mismatching sidecar is rejected as , /// naming the sidecar, with boundaries recovered when .eh_frame allows. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_MismatchedSidecar_ReportsIdMismatch() { var dir = Directory.CreateTempSubdirectory("dotsider-elf-"); @@ -210,11 +217,11 @@ public void Read_MismatchedSidecar_ReportsIdMismatch() var result = NativeSymbolReader.Read(exePath, File.ReadAllBytes(exePath), []); - Assert.Equal(NativeSymbolStatus.IdMismatch, result.Status); - Assert.Equal(NativeSymbolSource.EhFrameFallback, result.Source); - Assert.Contains("app.dbg", result.Diagnostic); - var boundary = Assert.Single(result.Symbols); - Assert.Equal(NativeSymbolKind.Boundary, boundary.Kind); + Assert.AreEqual(NativeSymbolStatus.IdMismatch, result.Status); + Assert.AreEqual(NativeSymbolSource.EhFrameFallback, result.Source); + Assert.Contains("app.dbg", result.Diagnostic!); + var boundary = Assert.ContainsSingle(result.Symbols); + Assert.AreEqual(NativeSymbolKind.Boundary, boundary.Kind); } finally { @@ -227,7 +234,8 @@ public void Read_MismatchedSidecar_ReportsIdMismatch() /// , and /// when there is no unwind data either. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_NoSidecar_FallsBackToEhFrameThenNoSymbolFile() { var dir = Directory.CreateTempSubdirectory("dotsider-elf-"); @@ -238,17 +246,17 @@ public void Read_NoSidecar_FallsBackToEhFrameThenNoSymbolFile() (".eh_frame", 0x3000, SyntheticImageBuilders.EhFrame((0x1010, 0x40), (0x1050, 0x20))))); var result = NativeSymbolReader.Read(withUnwind, File.ReadAllBytes(withUnwind), []); - Assert.Equal(NativeSymbolStatus.FallbackOnly, result.Status); - Assert.Equal(NativeSymbolSource.EhFrameFallback, result.Source); - Assert.Equal(2, result.Symbols.Count); - Assert.All(result.Symbols, s => Assert.Equal(NativeSymbolKind.Boundary, s.Kind)); + Assert.AreEqual(NativeSymbolStatus.FallbackOnly, result.Status); + Assert.AreEqual(NativeSymbolSource.EhFrameFallback, result.Source); + Assert.HasCount(2, result.Symbols); + TestAssert.All(result.Symbols, s => Assert.AreEqual(NativeSymbolKind.Boundary, s.Kind)); var bare = Write(dir.FullName, "bare", SyntheticImageBuilders.BuildElf( (".text", 0x1000, new byte[0x100]))); var empty = NativeSymbolReader.Read(bare, File.ReadAllBytes(bare), []); - Assert.Equal(NativeSymbolStatus.NoSymbolFile, empty.Status); - Assert.Empty(empty.Symbols); + Assert.AreEqual(NativeSymbolStatus.NoSymbolFile, empty.Status); + Assert.IsEmpty(empty.Symbols); } finally { @@ -260,7 +268,8 @@ public void Read_NoSidecar_FallsBackToEhFrameThenNoSymbolFile() /// Verifies a sidecar whose debug sections are SHF_COMPRESSED — the GNU toolchain /// default that produces zlib payloads behind an Elf64_Chdr — inflates and loads. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_CompressedDebugSections_InflateAndLoad() { var dir = Directory.CreateTempSubdirectory("dotsider-elf-"); @@ -278,11 +287,11 @@ public void Read_CompressedDebugSections_InflateAndLoad() var result = NativeSymbolReader.Read(exePath, File.ReadAllBytes(exePath), []); - Assert.Equal(NativeSymbolStatus.Loaded, result.Status); - Assert.Equal(NativeSymbolSource.Dwarf, result.Source); - var symbol = Assert.Single(result.Symbols); - Assert.Equal("frost_main", symbol.Name); - Assert.Equal(0x40, symbol.Size); + Assert.AreEqual(NativeSymbolStatus.Loaded, result.Status); + Assert.AreEqual(NativeSymbolSource.Dwarf, result.Source); + var symbol = Assert.ContainsSingle(result.Symbols); + Assert.AreEqual("frost_main", symbol.Name); + Assert.AreEqual(0x40, symbol.Size); } finally { @@ -294,7 +303,8 @@ public void Read_CompressedDebugSections_InflateAndLoad() /// Verifies a matched sidecar with unreadable debug data reports /// . /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_MatchedButUnreadableSidecar_ReportsCorrupt() { var dir = Directory.CreateTempSubdirectory("dotsider-elf-"); @@ -309,8 +319,8 @@ public void Read_MatchedButUnreadableSidecar_ReportsCorrupt() var result = NativeSymbolReader.Read(exePath, File.ReadAllBytes(exePath), []); - Assert.Equal(NativeSymbolStatus.CorruptSymbolFile, result.Status); - Assert.Contains("no readable symbols", result.Diagnostic); + Assert.AreEqual(NativeSymbolStatus.CorruptSymbolFile, result.Status); + Assert.Contains("no readable symbols", result.Diagnostic!); } finally { @@ -323,50 +333,50 @@ public void Read_MatchedButUnreadableSidecar_ReportsCorrupt() /// managed names, attributes a user function to a source file and line, and carries data /// categories from the symtab pass. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_NativeAotExeWithDbg_UsesDwarfSource() { - Assert.SkipWhen(!HasDbg, "native .dbg not present on this platform"); + TestSkip.When(!HasDbg, "native .dbg not present on this platform"); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var info = NativeSymbolReader.Read( - samples.NativeAotConsoleExe!, analyzer.RawBytes.ToArray(), analyzer.RecoveredTypes); - - Assert.Equal(NativeSymbolSource.Dwarf, info.Source); - Assert.Equal(NativeSymbolStatus.Loaded, info.Status); - Assert.True(info.Symbols.Count > 1000, - $"expected a real symbol population, got {info.Symbols.Count}"); - Assert.Contains(info.Symbols, s => s.ManagedName is not null && s.IsExactMatch); - Assert.Contains(info.Symbols, - s => s.Kind == NativeSymbolKind.Function && s.SourceFile is not null && s.Line > 0); - Assert.Contains(info.Symbols, s => s.Kind is NativeSymbolKind.MethodTable - or NativeSymbolKind.Statics or NativeSymbolKind.FrozenObject); + Samples.NativeAotConsoleExe!, analyzer.RawBytes.ToArray(), analyzer.RecoveredTypes); + + Assert.AreEqual(NativeSymbolSource.Dwarf, info.Source); + Assert.AreEqual(NativeSymbolStatus.Loaded, info.Status); + Assert.IsGreaterThan(1000, info.Symbols.Count, $"expected a real symbol population, got {info.Symbols.Count}"); + Assert.Contains(s => s.ManagedName is not null && s.IsExactMatch, info.Symbols); + Assert.Contains(s => s.Kind == NativeSymbolKind.Function && s.SourceFile is not null && s.Line > 0, info.Symbols); + Assert.Contains(s => s.Kind is NativeSymbolKind.MethodTable + or NativeSymbolKind.Statics or NativeSymbolKind.FrozenObject, info.Symbols); } /// /// Verifies the real exe copied away from its .dbg falls back to .eh_frame /// boundaries. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_NativeAotExeCopiedAway_FallsBackToEhFrame() { - Assert.SkipWhen(!HasDbg, "native .dbg not present on this platform"); + TestSkip.When(!HasDbg, "native .dbg not present on this platform"); - var exeBytes = File.ReadAllBytes(samples.NativeAotConsoleExe!); - Assert.SkipWhen(ElfImageReader.TryGetSection(exeBytes, ".debug_info", out _), + var exeBytes = File.ReadAllBytes(Samples.NativeAotConsoleExe!); + TestSkip.When(ElfImageReader.TryGetSection(exeBytes, ".debug_info", out _), "exe is unstripped; it would read its own DWARF"); var dir = Directory.CreateTempSubdirectory("dotsider-ehframe-"); try { - var exeCopy = Path.Combine(dir.FullName, Path.GetFileName(samples.NativeAotConsoleExe!)); - File.Copy(samples.NativeAotConsoleExe!, exeCopy); + var exeCopy = Path.Combine(dir.FullName, Path.GetFileName(Samples.NativeAotConsoleExe!)); + File.Copy(Samples.NativeAotConsoleExe!, exeCopy); var info = NativeSymbolReader.Read(exeCopy, File.ReadAllBytes(exeCopy), []); - Assert.Equal(NativeSymbolSource.EhFrameFallback, info.Source); - Assert.NotEmpty(info.Symbols); - Assert.All(info.Symbols, s => Assert.Equal(NativeSymbolKind.Boundary, s.Kind)); + Assert.AreEqual(NativeSymbolSource.EhFrameFallback, info.Source); + Assert.IsNotEmpty(info.Symbols); + TestAssert.All(info.Symbols, s => Assert.AreEqual(NativeSymbolKind.Boundary, s.Kind)); } finally { diff --git a/tests/Dotsider.Tests/NativeSymbolReaderMachOTests.cs b/tests/Dotsider.Tests/NativeSymbolReaderMachOTests.cs index 99623a32..71b4d20c 100644 --- a/tests/Dotsider.Tests/NativeSymbolReaderMachOTests.cs +++ b/tests/Dotsider.Tests/NativeSymbolReaderMachOTests.cs @@ -10,9 +10,11 @@ namespace Dotsider.Tests; /// slice selection and ambiguity, and the LC_FUNCTION_STARTS fallback chain — plus the /// real NativeAOT fixture on the macOS leg (where its symbol file is a dSYM). /// -[Collection("SampleAssemblies")] -public class NativeSymbolReaderMachOTests(SampleAssemblyFixture samples) +[TestClass] +public class NativeSymbolReaderMachOTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private const uint ExecFlags = 0x8000_0400; private const byte SectType = 0x0E; @@ -52,7 +54,8 @@ private static string WriteDsym(string directory, string imageName, byte[] inner /// /// Verifies an image without a dSYM loads its own nlist as a named primary source. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_NlistOnly_LoadsFromImage() { var dir = Directory.CreateTempSubdirectory("dotsider-macho-"); @@ -66,12 +69,12 @@ public void Read_NlistOnly_LoadsFromImage() var result = NativeSymbolReader.Read(path, File.ReadAllBytes(path), []); - Assert.Equal(NativeSymbolSource.MachONlist, result.Source); - Assert.Equal(NativeSymbolStatus.Loaded, result.Status); - Assert.Equal(path, result.Path); - var symbol = Assert.Single(result.Symbols); - Assert.Equal("frost_main", symbol.Name); - Assert.Equal("__text", symbol.Section); + Assert.AreEqual(NativeSymbolSource.MachONlist, result.Source); + Assert.AreEqual(NativeSymbolStatus.Loaded, result.Status); + Assert.AreEqual(path, result.Path); + var symbol = Assert.ContainsSingle(result.Symbols); + Assert.AreEqual("frost_main", symbol.Name); + Assert.AreEqual("__text", symbol.Section); } finally { @@ -83,7 +86,8 @@ public void Read_NlistOnly_LoadsFromImage() /// Verifies a UUID-matched dSYM merges its DWARF functions and its nlist data symbols, with /// file offsets re-anchored onto the analyzed image. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_MatchingDsym_MergesDwarfAndNlist() { var dir = Directory.CreateTempSubdirectory("dotsider-macho-"); @@ -114,20 +118,20 @@ public void Read_MatchingDsym_MergesDwarfAndNlist() var result = NativeSymbolReader.Read(path, File.ReadAllBytes(path), []); - Assert.Equal(NativeSymbolSource.Dsym, result.Source); - Assert.Equal(NativeSymbolStatus.Loaded, result.Status); - Assert.Equal(dsymPath, result.Path); - Assert.Null(result.Diagnostic); + Assert.AreEqual(NativeSymbolSource.Dsym, result.Source); + Assert.AreEqual(NativeSymbolStatus.Loaded, result.Status); + Assert.AreEqual(dsymPath, result.Path); + Assert.IsNull(result.Diagnostic); - var function = Assert.Single(result.Symbols, s => s.Kind == NativeSymbolKind.Function); - Assert.Equal("frost_main", function.Name); - Assert.Equal(0x40, function.Size); - Assert.Equal("__text", function.Section); // mapped through the image, not the dSYM - Assert.NotNull(function.FileOffset); + var function = Assert.ContainsSingle(s => s.Kind == NativeSymbolKind.Function, result.Symbols); + Assert.AreEqual("frost_main", function.Name); + Assert.AreEqual(0x40, function.Size); + Assert.AreEqual("__text", function.Section); // mapped through the image, not the dSYM + Assert.IsNotNull(function.FileOffset); - var data = Assert.Single(result.Symbols, s => s.Kind == NativeSymbolKind.MethodTable); - Assert.Equal("_ZTV6Widget", data.Name); - Assert.Equal("__const", data.Section); // the dSYM's nlist, re-anchored on the image + var data = Assert.ContainsSingle(s => s.Kind == NativeSymbolKind.MethodTable, result.Symbols); + Assert.AreEqual("_ZTV6Widget", data.Name); + Assert.AreEqual("__const", data.Section); // the dSYM's nlist, re-anchored on the image } finally { @@ -140,7 +144,8 @@ public void Read_MatchingDsym_MergesDwarfAndNlist() /// , with boundaries recovered from /// LC_FUNCTION_STARTS. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_MismatchedDsym_ReportsIdMismatch() { var dir = Directory.CreateTempSubdirectory("dotsider-macho-"); @@ -160,12 +165,12 @@ public void Read_MismatchedDsym_ReportsIdMismatch() var result = NativeSymbolReader.Read(path, File.ReadAllBytes(path), []); - Assert.Equal(NativeSymbolStatus.IdMismatch, result.Status); - Assert.Equal(NativeSymbolSource.FunctionStartsFallback, result.Source); - Assert.Contains("UUID", result.Diagnostic); - var boundary = Assert.Single(result.Symbols); - Assert.Equal(NativeSymbolKind.Boundary, boundary.Kind); - Assert.Equal(0xF0, boundary.Size); // clamped to __text's end + Assert.AreEqual(NativeSymbolStatus.IdMismatch, result.Status); + Assert.AreEqual(NativeSymbolSource.FunctionStartsFallback, result.Source); + Assert.Contains("UUID", result.Diagnostic!); + var boundary = Assert.ContainsSingle(result.Symbols); + Assert.AreEqual(NativeSymbolKind.Boundary, boundary.Kind); + Assert.AreEqual(0xF0, boundary.Size); // clamped to __text's end } finally { @@ -178,7 +183,8 @@ public void Read_MismatchedDsym_ReportsIdMismatch() /// , then /// when nothing at all is present. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_NoSymbols_FallsBackToFunctionStartsThenNoSymbolFile() { var dir = Directory.CreateTempSubdirectory("dotsider-macho-"); @@ -190,16 +196,16 @@ public void Read_NoSymbols_FallsBackToFunctionStartsThenNoSymbolFile() functionStarts: new DwarfBlob().ULeb(0x1010).ULeb(0x40).ULeb(0).ToArray())); var result = NativeSymbolReader.Read(withStarts, File.ReadAllBytes(withStarts), []); - Assert.Equal(NativeSymbolStatus.FallbackOnly, result.Status); - Assert.Equal(NativeSymbolSource.FunctionStartsFallback, result.Source); - Assert.NotEmpty(result.Symbols); + Assert.AreEqual(NativeSymbolStatus.FallbackOnly, result.Status); + Assert.AreEqual(NativeSymbolSource.FunctionStartsFallback, result.Source); + Assert.IsNotEmpty(result.Symbols); var bare = Path.Combine(dir.FullName, "bare"); File.WriteAllBytes(bare, SyntheticImageBuilders.BuildMachO([TextSegment()])); var empty = NativeSymbolReader.Read(bare, File.ReadAllBytes(bare), []); - Assert.Equal(NativeSymbolStatus.NoSymbolFile, empty.Status); - Assert.Empty(empty.Symbols); + Assert.AreEqual(NativeSymbolStatus.NoSymbolFile, empty.Status); + Assert.IsEmpty(empty.Symbols); } finally { @@ -212,7 +218,8 @@ public void Read_NoSymbols_FallsBackToFunctionStartsThenNoSymbolFile() /// archive), and an undisambiguated archive reports /// naming the slices found. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_FatArchive_UuidSelectsSliceOrAmbiguous() { var dir = Directory.CreateTempSubdirectory("dotsider-macho-"); @@ -231,21 +238,21 @@ public void Read_FatArchive_UuidSelectsSliceOrAmbiguous() var result = NativeSymbolReader.Read(path, File.ReadAllBytes(path), []); - Assert.Equal(NativeSymbolStatus.Loaded, result.Status); - var symbol = Assert.Single(result.Symbols, s => s.Name == "sliced_fn"); + Assert.AreEqual(NativeSymbolStatus.Loaded, result.Status); + var symbol = Assert.ContainsSingle(s => s.Name == "sliced_fn", result.Symbols); var slices = MachOImageReader.ReadFatSlices(fat); - Assert.True(symbol.FileOffset > slices[1].Offset, - "the file offset must be shifted into the chosen slice's archive region"); + Assert.IsNotNull(symbol.FileOffset); + Assert.IsGreaterThan(slices[1].Offset, symbol.FileOffset.Value, "the file offset must be shifted into the chosen slice's archive region"); // No dSYM, no AOT signal: ambiguous, deterministically. var bare = Path.Combine(dir.FullName, "bare"); File.WriteAllBytes(bare, fat); var ambiguous = NativeSymbolReader.Read(bare, File.ReadAllBytes(bare), []); - Assert.Equal(NativeSymbolStatus.AmbiguousImage, ambiguous.Status); - Assert.Empty(ambiguous.Symbols); - Assert.Contains("0x100000c", ambiguous.Diagnostic); - Assert.Contains("0x1000007", ambiguous.Diagnostic); + Assert.AreEqual(NativeSymbolStatus.AmbiguousImage, ambiguous.Status); + Assert.IsEmpty(ambiguous.Symbols); + Assert.Contains("0x100000c", ambiguous.Diagnostic!); + Assert.Contains("0x1000007", ambiguous.Diagnostic!); } finally { @@ -258,7 +265,8 @@ public void Read_FatArchive_UuidSelectsSliceOrAmbiguous() /// image's UUID is selected and its DWARF read, instead of the fat header failing the /// identity check. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_FatDsym_SlicesToMatchingUuid() { var dir = Directory.CreateTempSubdirectory("dotsider-fatdsym-"); @@ -280,10 +288,10 @@ public void Read_FatDsym_SlicesToMatchingUuid() var result = NativeSymbolReader.Read(path, File.ReadAllBytes(path), []); - Assert.Equal(NativeSymbolStatus.Loaded, result.Status); - Assert.Equal(NativeSymbolSource.Dsym, result.Source); - var symbol = Assert.Single(result.Symbols); - Assert.Equal("frost_main", symbol.Name); + Assert.AreEqual(NativeSymbolStatus.Loaded, result.Status); + Assert.AreEqual(NativeSymbolSource.Dsym, result.Source); + var symbol = Assert.ContainsSingle(result.Symbols); + Assert.AreEqual("frost_main", symbol.Name); } finally { @@ -295,7 +303,8 @@ public void Read_FatDsym_SlicesToMatchingUuid() /// Verifies a fat dSYM with no slice carrying the image's UUID is rejected as /// , not silently read. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_FatDsym_NoMatchingSlice_ReportsIdMismatch() { var dir = Directory.CreateTempSubdirectory("dotsider-fatdsym-"); @@ -313,9 +322,9 @@ public void Read_FatDsym_NoMatchingSlice_ReportsIdMismatch() var result = NativeSymbolReader.Read(path, File.ReadAllBytes(path), []); - Assert.Equal(NativeSymbolStatus.IdMismatch, result.Status); - Assert.Equal(NativeSymbolSource.FunctionStartsFallback, result.Source); - Assert.Contains("UUID", result.Diagnostic); + Assert.AreEqual(NativeSymbolStatus.IdMismatch, result.Status); + Assert.AreEqual(NativeSymbolSource.FunctionStartsFallback, result.Source); + Assert.Contains("UUID", result.Diagnostic!); } finally { @@ -328,24 +337,23 @@ public void Read_FatDsym_NoMatchingSlice_ReportsIdMismatch() /// managed names, attributes a function to a source file and line, and carries data /// categories. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_NativeAotExeWithDsym_UsesDsymSource() { - Assert.SkipWhen(samples.NativeAotConsoleDsym is null, "dSYM bundle not present on this platform"); + TestSkip.When(Samples.NativeAotConsoleDsym is null, "dSYM bundle not present on this platform"); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var info = NativeSymbolReader.Read( - samples.NativeAotConsoleExe!, analyzer.RawBytes.ToArray(), analyzer.RecoveredTypes); - - Assert.Equal(NativeSymbolSource.Dsym, info.Source); - Assert.Equal(NativeSymbolStatus.Loaded, info.Status); - Assert.True(info.Symbols.Count > 1000, - $"expected a real symbol population, got {info.Symbols.Count}"); - Assert.Contains(info.Symbols, s => s.ManagedName is not null && s.IsExactMatch); - Assert.Contains(info.Symbols, - s => s.Kind == NativeSymbolKind.Function && s.SourceFile is not null && s.Line > 0); - Assert.Contains(info.Symbols, s => s.Kind is NativeSymbolKind.MethodTable - or NativeSymbolKind.Statics or NativeSymbolKind.FrozenObject); + Samples.NativeAotConsoleExe!, analyzer.RawBytes.ToArray(), analyzer.RecoveredTypes); + + Assert.AreEqual(NativeSymbolSource.Dsym, info.Source); + Assert.AreEqual(NativeSymbolStatus.Loaded, info.Status); + Assert.IsGreaterThan(1000, info.Symbols.Count, $"expected a real symbol population, got {info.Symbols.Count}"); + Assert.Contains(s => s.ManagedName is not null && s.IsExactMatch, info.Symbols); + Assert.Contains(s => s.Kind == NativeSymbolKind.Function && s.SourceFile is not null && s.Line > 0, info.Symbols); + Assert.Contains(s => s.Kind is NativeSymbolKind.MethodTable + or NativeSymbolKind.Statics or NativeSymbolKind.FrozenObject, info.Symbols); } /// @@ -353,12 +361,13 @@ public void Read_NativeAotExeWithDsym_UsesDsymSource() /// LC_SYMTAB count zeroed and no dSYM beside it, LC_FUNCTION_STARTS is forced /// regardless of what strip -x left in the symbol table. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_NativeAotExeSymtabZeroed_ForcesFunctionStarts() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var bytes = File.ReadAllBytes(samples.NativeAotConsoleExe!); - Assert.SkipWhen(!MachOImageReader.IsMachO(bytes), "exe is not a thin Mach-O on this platform"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + var bytes = File.ReadAllBytes(Samples.NativeAotConsoleExe!); + TestSkip.When(!MachOImageReader.IsMachO(bytes), "exe is not a thin Mach-O on this platform"); // Zero LC_SYMTAB's nsyms in place. var commandCount = BinaryPrimitives.ReadUInt32LittleEndian(bytes.AsSpan(16)); @@ -379,16 +388,16 @@ public void Read_NativeAotExeSymtabZeroed_ForcesFunctionStarts() var dir = Directory.CreateTempSubdirectory("dotsider-fstarts-"); try { - var copy = Path.Combine(dir.FullName, Path.GetFileName(samples.NativeAotConsoleExe!)); + var copy = Path.Combine(dir.FullName, Path.GetFileName(Samples.NativeAotConsoleExe!)); File.WriteAllBytes(copy, bytes); var info = NativeSymbolReader.Read(copy, File.ReadAllBytes(copy), []); - Assert.Equal(NativeSymbolSource.FunctionStartsFallback, info.Source); - Assert.Equal(NativeSymbolStatus.FallbackOnly, info.Status); - Assert.NotEmpty(info.Symbols); - Assert.All(info.Symbols, s => Assert.Equal(NativeSymbolKind.Boundary, s.Kind)); - Assert.All(info.Symbols, s => Assert.True(s.Size > 0)); + Assert.AreEqual(NativeSymbolSource.FunctionStartsFallback, info.Source); + Assert.AreEqual(NativeSymbolStatus.FallbackOnly, info.Status); + Assert.IsNotEmpty(info.Symbols); + TestAssert.All(info.Symbols, s => Assert.AreEqual(NativeSymbolKind.Boundary, s.Kind)); + TestAssert.All(info.Symbols, s => Assert.IsGreaterThan(0, s.Size)); } finally { diff --git a/tests/Dotsider.Tests/NativeSymbolReaderPeTests.cs b/tests/Dotsider.Tests/NativeSymbolReaderPeTests.cs index ce8c4f3d..63b64b72 100644 --- a/tests/Dotsider.Tests/NativeSymbolReaderPeTests.cs +++ b/tests/Dotsider.Tests/NativeSymbolReaderPeTests.cs @@ -8,53 +8,57 @@ namespace Dotsider.Tests; /// property, against the real NativeAOT fixture on /// the Windows leg (where its symbol file is a PDB). /// -[Collection("SampleAssemblies")] -public class NativeSymbolReaderPeTests(SampleAssemblyFixture samples) +[TestClass] +public class NativeSymbolReaderPeTests { - private bool HasPdb => - samples.NativeAotConsoleSymbols is not null - && samples.NativeAotConsoleSymbols.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase); + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + + private static bool HasPdb => + Samples.NativeAotConsoleSymbols is not null + && Samples.NativeAotConsoleSymbols.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase); /// /// Verifies the facade reads the matching PDB as the NativePdb source and demangles the entry /// point. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_NativeAotExeWithPdb_UsesNativePdbSource() { - Assert.SkipWhen(!HasPdb, "native PDB not present on this platform"); + TestSkip.When(!HasPdb, "native PDB not present on this platform"); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var info = NativeSymbolReader.Read( - samples.NativeAotConsoleExe!, analyzer.RawBytes.ToArray(), analyzer.RecoveredTypes); + Samples.NativeAotConsoleExe!, analyzer.RawBytes.ToArray(), analyzer.RecoveredTypes); - Assert.Equal(NativeSymbolSource.NativePdb, info.Source); - Assert.Equal(NativeSymbolStatus.Loaded, info.Status); - Assert.NotEmpty(info.Symbols); - Assert.Contains(info.Symbols, s => s.ManagedName is not null && s.IsExactMatch); + Assert.AreEqual(NativeSymbolSource.NativePdb, info.Source); + Assert.AreEqual(NativeSymbolStatus.Loaded, info.Status); + Assert.IsNotEmpty(info.Symbols); + Assert.Contains(s => s.ManagedName is not null && s.IsExactMatch, info.Symbols); } /// /// Verifies a Native AOT binary copied away from its PDB falls back to real .pdata boundaries. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_NativeAotExeWithoutPdb_FallsBackToPdataBoundaries() { - Assert.SkipWhen(!HasPdb, "native PDB not present on this platform"); + TestSkip.When(!HasPdb, "native PDB not present on this platform"); var dir = Directory.CreateTempSubdirectory("dotsider-pdata-"); try { - var exeCopy = Path.Combine(dir.FullName, Path.GetFileName(samples.NativeAotConsoleExe!)); - File.Copy(samples.NativeAotConsoleExe!, exeCopy); + var exeCopy = Path.Combine(dir.FullName, Path.GetFileName(Samples.NativeAotConsoleExe!)); + File.Copy(Samples.NativeAotConsoleExe!, exeCopy); var bytes = File.ReadAllBytes(exeCopy); var info = NativeSymbolReader.Read(exeCopy, bytes, []); - Assert.Equal(NativeSymbolSource.PdataFallback, info.Source); - Assert.NotEmpty(info.Symbols); - Assert.All(info.Symbols, s => Assert.Equal(NativeSymbolKind.Boundary, s.Kind)); - Assert.All(info.Symbols, s => Assert.True(s.Size > 0)); + Assert.AreEqual(NativeSymbolSource.PdataFallback, info.Source); + Assert.IsNotEmpty(info.Symbols); + TestAssert.All(info.Symbols, s => Assert.AreEqual(NativeSymbolKind.Boundary, s.Kind)); + TestAssert.All(info.Symbols, s => Assert.IsGreaterThan(0, s.Size)); } finally { @@ -67,31 +71,32 @@ public void Read_NativeAotExeWithoutPdb_FallsBackToPdataBoundaries() /// with boundaries, instead of hiding behind /// "no matching PDB". /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Read_StalePdbBesideExe_ReportsIdMismatch() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var bytes = File.ReadAllBytes(samples.NativeAotConsoleExe!); - Assert.SkipWhen(bytes.Length < 2 || bytes[0] != (byte)'M' || bytes[1] != (byte)'Z', + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + var bytes = File.ReadAllBytes(Samples.NativeAotConsoleExe!); + TestSkip.When(bytes.Length < 2 || bytes[0] != (byte)'M' || bytes[1] != (byte)'Z', "exe is not a PE on this platform"); var id = PeCodeView.TryRead(bytes); - Assert.SkipWhen(id is null, "exe carries no RSDS record"); + TestSkip.When(id is null, "exe carries no RSDS record"); var dir = Directory.CreateTempSubdirectory("dotsider-stalepdb-"); try { - var exeCopy = Path.Combine(dir.FullName, Path.GetFileName(samples.NativeAotConsoleExe!)); - File.Copy(samples.NativeAotConsoleExe!, exeCopy); + var exeCopy = Path.Combine(dir.FullName, Path.GetFileName(Samples.NativeAotConsoleExe!)); + File.Copy(Samples.NativeAotConsoleExe!, exeCopy); File.WriteAllBytes( Path.Combine(dir.FullName, Path.GetFileNameWithoutExtension(exeCopy) + ".pdb"), SyntheticImageBuilders.BuildMsf(4096, PdbInfoStream(Guid.NewGuid(), id!.Value.Age))); var info = NativeSymbolReader.Read(exeCopy, File.ReadAllBytes(exeCopy), []); - Assert.Equal(NativeSymbolStatus.IdMismatch, info.Status); - Assert.Equal(NativeSymbolSource.PdataFallback, info.Source); - Assert.NotEmpty(info.Symbols); - Assert.Contains(".pdb", info.Diagnostic); + Assert.AreEqual(NativeSymbolStatus.IdMismatch, info.Status); + Assert.AreEqual(NativeSymbolSource.PdataFallback, info.Source); + Assert.IsNotEmpty(info.Symbols); + Assert.Contains(".pdb", info.Diagnostic!); } finally { @@ -106,35 +111,36 @@ public async Task Read_StalePdbBesideExe_ReportsIdMismatch() /// , and a matching PDB with no readable /// symbol streams does too — neither masquerades as a missing file. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Read_CorruptOrEmptyPdb_ReportsCorruptSymbolFile() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var bytes = File.ReadAllBytes(samples.NativeAotConsoleExe!); - Assert.SkipWhen(bytes.Length < 2 || bytes[0] != (byte)'M' || bytes[1] != (byte)'Z', + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + var bytes = File.ReadAllBytes(Samples.NativeAotConsoleExe!); + TestSkip.When(bytes.Length < 2 || bytes[0] != (byte)'M' || bytes[1] != (byte)'Z', "exe is not a PE on this platform"); var id = PeCodeView.TryRead(bytes); - Assert.SkipWhen(id is null, "exe carries no RSDS record"); + TestSkip.When(id is null, "exe carries no RSDS record"); var dir = Directory.CreateTempSubdirectory("dotsider-badpdb-"); try { - var exeCopy = Path.Combine(dir.FullName, Path.GetFileName(samples.NativeAotConsoleExe!)); - File.Copy(samples.NativeAotConsoleExe!, exeCopy); + var exeCopy = Path.Combine(dir.FullName, Path.GetFileName(Samples.NativeAotConsoleExe!)); + File.Copy(Samples.NativeAotConsoleExe!, exeCopy); var pdbPath = Path.Combine(dir.FullName, Path.GetFileNameWithoutExtension(exeCopy) + ".pdb"); // Not an MSF container at all. File.WriteAllBytes(pdbPath, [0xDE, 0xAD, 0xBE, 0xEF]); var unreadable = NativeSymbolReader.Read(exeCopy, File.ReadAllBytes(exeCopy), []); - Assert.Equal(NativeSymbolStatus.CorruptSymbolFile, unreadable.Status); - Assert.NotEmpty(unreadable.Symbols); + Assert.AreEqual(NativeSymbolStatus.CorruptSymbolFile, unreadable.Status); + Assert.IsNotEmpty(unreadable.Symbols); // Identity matches, but there is no DBI stream to read symbols from. File.WriteAllBytes(pdbPath, SyntheticImageBuilders.BuildMsf(4096, PdbInfoStream(id!.Value.Guid, id.Value.Age))); var empty = NativeSymbolReader.Read(exeCopy, File.ReadAllBytes(exeCopy), []); - Assert.Equal(NativeSymbolStatus.CorruptSymbolFile, empty.Status); - Assert.Contains("no readable symbols", empty.Diagnostic); + Assert.AreEqual(NativeSymbolStatus.CorruptSymbolFile, empty.Status); + Assert.Contains("no readable symbols", empty.Diagnostic!); } finally { @@ -157,14 +163,15 @@ private static byte[] PdbInfoStream(Guid guid, int age) /// Verifies the analyzer surfaces native symbols for a Native AOT binary and null for a /// managed assembly. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeSymbols_ManagedIsNull_NativeAotIsPresent() { - using var managed = new AssemblyAnalyzer(samples.RichLibraryDll); - Assert.Null(managed.NativeSymbols); + using var managed = new AssemblyAnalyzer(Samples.RichLibraryDll); + Assert.IsNull(managed.NativeSymbols); - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - using var aot = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); - Assert.NotNull(aot.NativeSymbols); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + using var aot = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); + Assert.IsNotNull(aot.NativeSymbols); } } diff --git a/tests/Dotsider.Tests/NativeSyntaxDecorationTests.cs b/tests/Dotsider.Tests/NativeSyntaxDecorationTests.cs index 2ef78fba..aa8fb7e2 100644 --- a/tests/Dotsider.Tests/NativeSyntaxDecorationTests.cs +++ b/tests/Dotsider.Tests/NativeSyntaxDecorationTests.cs @@ -10,10 +10,12 @@ namespace Dotsider.Tests; /// instructions' spans (address, mnemonic, operands, target) rather /// than by re-parsing the rendered text, and stays inert until fed instructions. /// +[TestClass] public class NativeSyntaxDecorationTests { /// Verifies the provider emits address and mnemonic spans for each instruction line. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetDecorations_NativeListing_ColorsMnemonicAndAddress() { // mov rbp, rsp ; sub rsp, 0x20 ; call rel32 @@ -25,17 +27,18 @@ public void GetDecorations_NativeListing_ColorsMnemonicAndAddress() var spans = provider.GetDecorations(1, doc.LineCount, doc); - Assert.NotEmpty(spans); + Assert.IsNotEmpty(spans); // Every instruction line contributes at least an address and a mnemonic span. - Assert.True(spans.Count >= instructions.Count(i => i.DisplayLine is not null) * 2); + Assert.IsGreaterThanOrEqualTo(instructions.Count(i => i.DisplayLine is not null) * 2, spans.Count); } /// Verifies the provider produces nothing until it is fed an instruction list. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GetDecorations_NoInstructions_IsInert() { var doc = new Hex1bDocument("0x1000: 90 nop"); var provider = new NativeSyntaxDecorationProvider(); - Assert.Empty(provider.GetDecorations(1, doc.LineCount, doc)); + Assert.IsEmpty(provider.GetDecorations(1, doc.LineCount, doc)); } } diff --git a/tests/Dotsider.Tests/NetFxAssemblyResolutionClr2Tests.cs b/tests/Dotsider.Tests/NetFxAssemblyResolutionClr2Tests.cs index d95fdc53..f21c44c4 100644 --- a/tests/Dotsider.Tests/NetFxAssemblyResolutionClr2Tests.cs +++ b/tests/Dotsider.Tests/NetFxAssemblyResolutionClr2Tests.cs @@ -10,44 +10,47 @@ namespace Dotsider.Tests; /// for Clr2 contexts and produce the same outcome the live runtime did. /// Mirror of for the Clr2 path. /// -[Collection("SampleAssemblies")] -public sealed class NetFxAssemblyResolutionClr2Tests(SampleAssemblyFixture samples) +[TestClass] +public sealed class NetFxAssemblyResolutionClr2Tests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// The dep-graph for the CLR 2 root has no Unresolved or IdentityMismatch /// leaves for any reference the live runtime loaded, and the SharedDep node appears /// exactly once at v2.0.0.0 even though UsesSharedV1 references v1.0.0.0 and UsesSharedV2 /// references v2.0.0.0 — both collapse via the bindingRedirect. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DependencyGraph_Clr2_NoUnresolvedLeaves_AndSharedDepCollapses() { SkipIfNotWindows(); SkipIfClr2Absent(); - Assert.NotNull(samples.NetFxBindingRedirectsClr2Exe); + Assert.IsNotNull(Samples.NetFxBindingRedirectsClr2Exe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsClr2Exe!); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsClr2Exe!); var graph = DependencyGraphBuilder.Build(analyzer); // SharedDep collapses: exactly one node, bound version 2.0.0.0. var sharedDepNodes = graph.Nodes .Where(n => n.Name == "NetFxBindingRedirects.Clr2.SharedDep") .ToList(); - Assert.Single(sharedDepNodes); - Assert.Equal("2.0.0.0", sharedDepNodes[0].Version); - Assert.False(sharedDepNodes[0].Unresolved); - Assert.NotEqual(AssemblyProvenance.IdentityMismatch, + Assert.ContainsSingle(sharedDepNodes); + Assert.AreEqual("2.0.0.0", sharedDepNodes[0].Version); + Assert.IsFalse(sharedDepNodes[0].Unresolved); + Assert.AreNotEqual(AssemblyProvenance.IdentityMismatch, graph.NavigationById[sharedDepNodes[0].Id].Provenance); - Assert.NotEqual(AssemblyProvenance.Unresolved, + Assert.AreNotEqual(AssemblyProvenance.Unresolved, graph.NavigationById[sharedDepNodes[0].Id].Provenance); // mscorlib resolves to the v2.0.50727 framework runtime directory and is framework. var mscorlib = graph.Nodes.FirstOrDefault(n => n.Name == "mscorlib"); - Assert.NotNull(mscorlib); - Assert.False(mscorlib!.Unresolved); + Assert.IsNotNull(mscorlib); + Assert.IsFalse(mscorlib!.Unresolved); var mscorlibNav = graph.NavigationById[mscorlib.Id]; - Assert.True(mscorlibNav.IsFrameworkAssembly); - Assert.Equal(AssemblyProvenance.FrameworkRuntimeDirectory, mscorlibNav.Provenance); + Assert.IsTrue(mscorlibNav.IsFrameworkAssembly); + Assert.AreEqual(AssemblyProvenance.FrameworkRuntimeDirectory, mscorlibNav.Provenance); var mscorlibPath = mscorlibNav.Resolved switch { ResolvedAssembly.FromFile f => f.Path, @@ -60,28 +63,29 @@ public void DependencyGraph_Clr2_NoUnresolvedLeaves_AndSharedDepCollapses() foreach (var node in graph.Nodes) { var nav = graph.NavigationById[node.Id]; - Assert.NotEqual(AssemblyProvenance.Unresolved, nav.Provenance); - Assert.NotEqual(AssemblyProvenance.IdentityMismatch, nav.Provenance); + Assert.AreNotEqual(AssemblyProvenance.Unresolved, nav.Provenance); + Assert.AreNotEqual(AssemblyProvenance.IdentityMismatch, nav.Provenance); } } /// At least one edge into the redirected SharedDep node carries a per-edge /// RequestedIdentity recording the pre-redirect v1.0.0.0 — proves the dep-graph keeps /// the request-side identity for diagnostics even when the node is keyed on v2.0.0.0. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DependencyGraph_RedirectedRefs_PreservePreRedirectIdentityOnEdge() { SkipIfNotWindows(); SkipIfClr2Absent(); - Assert.NotNull(samples.NetFxBindingRedirectsClr2Exe); + Assert.IsNotNull(Samples.NetFxBindingRedirectsClr2Exe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsClr2Exe!); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsClr2Exe!); var graph = DependencyGraphBuilder.Build(analyzer); var sharedDepNode = graph.Nodes.Single(n => n.Name == "NetFxBindingRedirects.Clr2.SharedDep"); var edges = graph.Edges.Where(e => e.TargetId == sharedDepNode.Id).ToList(); - Assert.NotEmpty(edges); - Assert.Contains(edges, e => e.RequestedIdentity is { Version: "1.0.0.0" }); + Assert.IsNotEmpty(edges); + Assert.Contains(e => e.RequestedIdentity is { Version: "1.0.0.0" }, edges); } /// @@ -89,23 +93,24 @@ public void DependencyGraph_RedirectedRefs_PreservePreRedirectIdentityOnEdge() /// framework. Covers the v3.0 reference-assemblies allowlist end-to-end through the /// dep-graph builder's IsFrameworkAssembly classification. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DependencyGraph_Clr2_WindowsBase_v3_0_ResolvesViaGacAndIsClassifiedFramework() { SkipIfNotWindows(); SkipIfClr2Absent(); - Assert.NotNull(samples.NetFxBindingRedirectsClr2Exe); + Assert.IsNotNull(Samples.NetFxBindingRedirectsClr2Exe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsClr2Exe!); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsClr2Exe!); var graph = DependencyGraphBuilder.Build(analyzer); var windowsBase = graph.Nodes.FirstOrDefault(n => n.Name == "WindowsBase"); - Assert.NotNull(windowsBase); - Assert.Equal("3.0.0.0", windowsBase!.Version); - Assert.False(windowsBase.Unresolved); + Assert.IsNotNull(windowsBase); + Assert.AreEqual("3.0.0.0", windowsBase!.Version); + Assert.IsFalse(windowsBase.Unresolved); var nav = graph.NavigationById[windowsBase.Id]; - Assert.Equal(AssemblyProvenance.Gac, nav.Provenance); - Assert.True(nav.IsFrameworkAssembly); + Assert.AreEqual(AssemblyProvenance.Gac, nav.Provenance); + Assert.IsTrue(nav.IsFrameworkAssembly); } /// @@ -114,40 +119,42 @@ public void DependencyGraph_Clr2_WindowsBase_v3_0_ResolvesViaGacAndIsClassifiedF /// whose /// records the redirect for SharedDep v1 → v2. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ResolveAssemblyByIdentity_Clr2_SharedDepV1_AppliedPolicyPopulated() { SkipIfNotWindows(); SkipIfClr2Absent(); - Assert.NotNull(samples.NetFxBindingRedirectsClr2Exe); + Assert.IsNotNull(Samples.NetFxBindingRedirectsClr2Exe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsClr2Exe!); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsClr2Exe!); var ctx = NetFxBindingContext.TryBuild(analyzer); - Assert.NotNull(ctx); + Assert.IsNotNull(ctx); var requested = new AssemblyRefInfo( "NetFxBindingRedirects.Clr2.SharedDep", "1.0.0.0", "neutral", "e89d2d22dd26920d"); var resolution = AssemblyAnalyzer.ResolveAssemblyByIdentity( - referencingAssemblyPath: samples.NetFxBindingRedirectsClr2Exe!, + referencingAssemblyPath: Samples.NetFxBindingRedirectsClr2Exe!, identity: requested, netFxBindingContext: ctx); - Assert.NotNull(resolution.Resolved); - Assert.NotNull(resolution.AppliedPolicy); - Assert.Equal(new Version(1, 0, 0, 0), resolution.AppliedPolicy!.RequestedVersion); - Assert.Equal(new Version(2, 0, 0, 0), resolution.AppliedPolicy.BoundVersion); - Assert.Equal("2.0.0.0", resolution.LoadedIdentity?.Version); + Assert.IsNotNull(resolution.Resolved); + Assert.IsNotNull(resolution.AppliedPolicy); + Assert.AreEqual(new Version(1, 0, 0, 0), resolution.AppliedPolicy!.RequestedVersion); + Assert.AreEqual(new Version(2, 0, 0, 0), resolution.AppliedPolicy.BoundVersion); + Assert.IsNotNull(resolution.LoadedIdentity); + Assert.AreEqual("2.0.0.0", resolution.LoadedIdentity.Version); } private static void SkipIfNotWindows() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - Assert.Skip("Test requires Windows (.NET Framework binder)."); + Assert.Inconclusive("Test requires Windows (.NET Framework binder)."); } - private void SkipIfClr2Absent() + private static void SkipIfClr2Absent() { - if (!samples.Clr2RuntimePresent) - Assert.Skip("CLR 2 runtime not installed on this host (no v2.0.50727 mscorlib)."); + if (!Samples.Clr2RuntimePresent) + Assert.Inconclusive("CLR 2 runtime not installed on this host (no v2.0.50727 mscorlib)."); } } diff --git a/tests/Dotsider.Tests/NetFxAssemblyResolutionTests.cs b/tests/Dotsider.Tests/NetFxAssemblyResolutionTests.cs index f3de0a93..521c9e1c 100644 --- a/tests/Dotsider.Tests/NetFxAssemblyResolutionTests.cs +++ b/tests/Dotsider.Tests/NetFxAssemblyResolutionTests.cs @@ -9,9 +9,11 @@ namespace Dotsider.Tests; /// AssemblyAnalyzer.ResolveAssemblyByIdentity, ImplementationAssemblyResolver — produces the /// same answer for any net48 reference and that .NET Core / .NET 5+ probe paths are unchanged. /// -[Collection("SampleAssemblies")] -public sealed class NetFxAssemblyResolutionTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public sealed class NetFxAssemblyResolutionTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// Clears resolution caches after each test so they don't leak across assertions. public void Dispose() { @@ -23,72 +25,75 @@ public void Dispose() /// AssemblyAnalyzer.ResolveAssemblyByIdentity routes through NetFxBinder when a net48 /// context is supplied: a redirected reference returns the bound identity + AppliedPolicy. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ResolveAssemblyByIdentity_NetFxRoot_RoutesThroughBinder() { SkipIfNotWindows(); - Assert.NotNull(samples.NetFxBindingRedirectsExe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsExe!); + Assert.IsNotNull(Samples.NetFxBindingRedirectsExe); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsExe!); var ctx = NetFxBindingContext.TryBuild(analyzer); - Assert.NotNull(ctx); + Assert.IsNotNull(ctx); var requested = new AssemblyRefInfo("Newtonsoft.Json", "12.0.0.0", "neutral", "30ad4fe6b2a6aeed"); var resolution = AssemblyAnalyzer.ResolveAssemblyByIdentity( - samples.NetFxBindingRedirectsExe!, requested, + Samples.NetFxBindingRedirectsExe!, requested, analyzer.TargetFramework, analyzer.PreferredRuntimePack, analyzer.SourceBundlePath, ctx); - Assert.NotNull(resolution.Resolved); - Assert.NotNull(resolution.AppliedPolicy); - Assert.Equal(new Version(13, 0, 0, 0), resolution.AppliedPolicy!.BoundVersion); - Assert.NotNull(resolution.LoadedIdentity); - Assert.Equal("13.0.0.0", resolution.LoadedIdentity!.Version); + Assert.IsNotNull(resolution.Resolved); + Assert.IsNotNull(resolution.AppliedPolicy); + Assert.AreEqual(new Version(13, 0, 0, 0), resolution.AppliedPolicy!.BoundVersion); + Assert.IsNotNull(resolution.LoadedIdentity); + Assert.AreEqual("13.0.0.0", resolution.LoadedIdentity!.Version); } /// /// Without a net48 context the existing probe chain runs unchanged. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NonNetFxRoot_NoBindingContextBuilt_ProbeChainUnchanged() { // HelloWorld is a .NET 10 root — no NetFxBindingContext. - using var helloAnalyzer = new AssemblyAnalyzer(samples.HelloWorldDll); - Assert.Null(NetFxBindingContext.TryBuild(helloAnalyzer)); + using var helloAnalyzer = new AssemblyAnalyzer(Samples.HelloWorldDll); + Assert.IsNull(NetFxBindingContext.TryBuild(helloAnalyzer)); // Resolve System.Runtime; with no context the call falls through to the .NET shared // framework path and returns a non-null result with the original (non-net48) provenance set. var requested = new AssemblyRefInfo("System.Runtime", "10.0.0.0", "neutral", "b03f5f7f11d50a3a"); var resolution = AssemblyAnalyzer.ResolveAssemblyByIdentity( - samples.HelloWorldDll, requested, + Samples.HelloWorldDll, requested, helloAnalyzer.TargetFramework, helloAnalyzer.PreferredRuntimePack, helloAnalyzer.SourceBundlePath, netFxBindingContext: null); - Assert.NotNull(resolution.Resolved); - Assert.Null(resolution.AppliedPolicy); - Assert.Null(resolution.LoadedIdentity); - Assert.NotEqual(AssemblyProvenance.Gac, resolution.Provenance); - Assert.NotEqual(AssemblyProvenance.FrameworkRuntimeDirectory, resolution.Provenance); + Assert.IsNotNull(resolution.Resolved); + Assert.IsNull(resolution.AppliedPolicy); + Assert.IsNull(resolution.LoadedIdentity); + Assert.AreNotEqual(AssemblyProvenance.Gac, resolution.Provenance); + Assert.AreNotEqual(AssemblyProvenance.FrameworkRuntimeDirectory, resolution.Provenance); } /// /// ImplementationAssemblyResolver routes through NetFxBinder when both the context and the /// referencing analyzer are supplied. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ImplementationAssemblyResolver_NetFxRoot_RoutesThroughBinder() { SkipIfNotWindows(); - Assert.NotNull(samples.NetFxBindingRedirectsExe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsExe!); + Assert.IsNotNull(Samples.NetFxBindingRedirectsExe); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsExe!); var ctx = NetFxBindingContext.TryBuild(analyzer); - Assert.NotNull(ctx); + Assert.IsNotNull(ctx); var resolved = ImplementationAssemblyResolver.Resolve( - samples.NetFxBindingRedirectsExe!, "Newtonsoft.Json", + Samples.NetFxBindingRedirectsExe!, "Newtonsoft.Json", declaringType: null, analyzer.TargetFramework, analyzer.PreferredRuntimePack, analyzer.SourceBundlePath, ctx, analyzer); - Assert.NotNull(resolved); - var fromFile = Assert.IsType(resolved); - Assert.Equal("Newtonsoft.Json.dll", Path.GetFileName(fromFile.Path)); + Assert.IsNotNull(resolved); + var fromFile = Assert.IsExactInstanceOfType(resolved); + Assert.AreEqual("Newtonsoft.Json.dll", Path.GetFileName(fromFile.Path)); } /// @@ -96,57 +101,59 @@ public void ImplementationAssemblyResolver_NetFxRoot_RoutesThroughBinder() /// leaves for assemblies whose oracle says the CLR loaded them, and collapses two requested /// versions of Newtonsoft.Json onto a single graph node keyed on the bound identity. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DependencyGraph_NetFxBindingRedirects_NoUnresolvedLeaves_AndOldNewDepCollapse() { SkipIfNotWindows(); - Assert.NotNull(samples.NetFxBindingRedirectsExe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsExe!); + Assert.IsNotNull(Samples.NetFxBindingRedirectsExe); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsExe!); var graph = DependencyGraphBuilder.Build(analyzer); // The Newtonsoft.Json node should appear exactly once even though OldDep references // 12.0.0.0 and NewDep references 13.0.0.0 — both redirect to 13.0.0.0. var newtonsoftNodes = graph.Nodes.Where(n => n.Name == "Newtonsoft.Json").ToList(); - Assert.Single(newtonsoftNodes); - Assert.Equal("13.0.0.0", newtonsoftNodes[0].Version); + Assert.ContainsSingle(newtonsoftNodes); + Assert.AreEqual("13.0.0.0", newtonsoftNodes[0].Version); // No Newtonsoft.Json node should be Unresolved or carry IdentityMismatch. - Assert.False(newtonsoftNodes[0].Unresolved); - Assert.NotEqual(AssemblyProvenance.IdentityMismatch, + Assert.IsFalse(newtonsoftNodes[0].Unresolved); + Assert.AreNotEqual(AssemblyProvenance.IdentityMismatch, graph.NavigationById[newtonsoftNodes[0].Id].Provenance); - Assert.NotEqual(AssemblyProvenance.Unresolved, + Assert.AreNotEqual(AssemblyProvenance.Unresolved, graph.NavigationById[newtonsoftNodes[0].Id].Provenance); // mscorlib should resolve to the framework runtime directory and be classified framework. var mscorlib = graph.Nodes.FirstOrDefault(n => n.Name == "mscorlib"); - Assert.NotNull(mscorlib); - Assert.False(mscorlib!.Unresolved); + Assert.IsNotNull(mscorlib); + Assert.IsFalse(mscorlib!.Unresolved); var mscorlibNav = graph.NavigationById[mscorlib.Id]; - Assert.True(mscorlibNav.IsFrameworkAssembly); - Assert.Equal(AssemblyProvenance.FrameworkRuntimeDirectory, mscorlibNav.Provenance); + Assert.IsTrue(mscorlibNav.IsFrameworkAssembly); + Assert.AreEqual(AssemblyProvenance.FrameworkRuntimeDirectory, mscorlibNav.Provenance); } /// /// At least one edge into the redirected Newtonsoft.Json node carries a per-edge /// RequestedIdentity recording the pre-redirect version (12.0.0.0 from OldDep). /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DependencyGraph_RedirectedRefs_PreservePreRedirectIdentityOnEdge() { SkipIfNotWindows(); - Assert.NotNull(samples.NetFxBindingRedirectsExe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsExe!); + Assert.IsNotNull(Samples.NetFxBindingRedirectsExe); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsExe!); var graph = DependencyGraphBuilder.Build(analyzer); var newtonsoftNode = graph.Nodes.Single(n => n.Name == "Newtonsoft.Json"); var edges = graph.Edges.Where(e => e.TargetId == newtonsoftNode.Id).ToList(); - Assert.NotEmpty(edges); - Assert.Contains(edges, e => e.RequestedIdentity is { Version: "12.0.0.0" }); + Assert.IsNotEmpty(edges); + Assert.Contains(e => e.RequestedIdentity is { Version: "12.0.0.0" }, edges); } private static void SkipIfNotWindows() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - Assert.Skip("Test requires Windows (.NET Framework binder)."); + Assert.Inconclusive("Test requires Windows (.NET Framework binder)."); } } diff --git a/tests/Dotsider.Tests/NetFxBinderClr2Tests.cs b/tests/Dotsider.Tests/NetFxBinderClr2Tests.cs index 27b0bb54..8770a9ce 100644 --- a/tests/Dotsider.Tests/NetFxBinderClr2Tests.cs +++ b/tests/Dotsider.Tests/NetFxBinderClr2Tests.cs @@ -11,13 +11,16 @@ namespace Dotsider.Tests; /// real on-disk file the CLR 2.0 fusion would also load. The synthetic temp-GAC tests are /// deliberately host-independent: they run on every Windows host even when CLR 2 isn't installed. /// -[Collection("SampleAssemblies")] -public sealed class NetFxBinderClr2Tests(SampleAssemblyFixture samples) +[TestClass] +public sealed class NetFxBinderClr2Tests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + // ---- Live-runtime tests (gated on Clr2RuntimePresent) ---- /// mscorlib resolves from the v2.0.50727 framework runtime directory. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_Mscorlib_FromV2FrameworkRuntimeDirectory_MatchesOracle() { SkipIfNotWindows(); @@ -25,15 +28,16 @@ public void Bind_Mscorlib_FromV2FrameworkRuntimeDirectory_MatchesOracle() var (ctx, oracle) = LoadFixture(); var requested = new AssemblyRefInfo("mscorlib", "2.0.0.0", "neutral", "b77a5c561934e089"); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.FrameworkRuntimeDirectory, result.Provenance); - Assert.NotNull(result.LoadedPath); + Assert.AreEqual(AssemblyProvenance.FrameworkRuntimeDirectory, result.Provenance); + Assert.IsNotNull(result.LoadedPath); Assert.Contains("v2.0.50727", result.LoadedPath, StringComparison.OrdinalIgnoreCase); - Assert.Equal(oracle["mscorlib"].Location, result.LoadedPath, ignoreCase: true); + Assert.AreEqual(oracle["mscorlib"].Location, result.LoadedPath, ignoreCase: true); } /// System.Drawing v2.0.0.0 resolves from %WINDIR%\assembly\GAC_MSIL with the /// no-prefix Clr2 token format 2.0.0.0__b03f5f7f11d50a3a. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_SystemDrawing_FromClr2Gac_MatchesOracle() { SkipIfNotWindows(); @@ -41,9 +45,9 @@ public void Bind_SystemDrawing_FromClr2Gac_MatchesOracle() var (ctx, oracle) = LoadFixture(); var requested = new AssemblyRefInfo("System.Drawing", "2.0.0.0", "neutral", "b03f5f7f11d50a3a"); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.Gac, result.Provenance); - Assert.NotNull(result.LoadedPath); - Assert.Equal(oracle["System.Drawing"].Location, result.LoadedPath, ignoreCase: true); + Assert.AreEqual(AssemblyProvenance.Gac, result.Provenance); + Assert.IsNotNull(result.LoadedPath); + Assert.AreEqual(oracle["System.Drawing"].Location, result.LoadedPath, ignoreCase: true); // No v4.0_ prefix on the Clr2 token. Assert.DoesNotContain("v4.0_", result.LoadedPath, StringComparison.OrdinalIgnoreCase); Assert.Contains("2.0.0.0__", result.LoadedPath, StringComparison.OrdinalIgnoreCase); @@ -52,7 +56,8 @@ public void Bind_SystemDrawing_FromClr2Gac_MatchesOracle() /// WindowsBase 3.0.0.0 — the v3.0 reference-assemblies allowlist coverage. Without /// the v3.0 branch in LoadFrameworkAssemblyNames, this would silently miss the /// unification table and resolve incorrectly. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_WindowsBase_v3_0_FromClr2Gac_ProvesV3ReferenceAssembliesAllowlist() { SkipIfNotWindows(); @@ -60,13 +65,14 @@ public void Bind_WindowsBase_v3_0_FromClr2Gac_ProvesV3ReferenceAssembliesAllowli var (ctx, oracle) = LoadFixture(); var requested = new AssemblyRefInfo("WindowsBase", "3.0.0.0", "neutral", "31bf3856ad364e35"); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.Gac, result.Provenance); - Assert.Equal(oracle["WindowsBase"].Location, result.LoadedPath, ignoreCase: true); + Assert.AreEqual(AssemblyProvenance.Gac, result.Provenance); + Assert.AreEqual(oracle["WindowsBase"].Location, result.LoadedPath, ignoreCase: true); } /// SharedDep v1.0.0.0 redirects to v2.0.0.0 via the appliesTo="v2.0.50727" /// bindingRedirect and resolves from app-local; AppliedPolicy is populated. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_SharedDep_RedirectAppliedAndAppLocal_MatchesOraclePath() { SkipIfNotWindows(); @@ -75,15 +81,16 @@ public void Bind_SharedDep_RedirectAppliedAndAppLocal_MatchesOraclePath() var requested = new AssemblyRefInfo( "NetFxBindingRedirects.Clr2.SharedDep", "1.0.0.0", "neutral", "e89d2d22dd26920d"); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.AppLocal, result.Provenance); - Assert.NotNull(result.AppliedPolicy); - Assert.Equal(new Version(2, 0, 0, 0), result.AppliedPolicy!.BoundVersion); - Assert.Equal(oracle["SharedDep_via_UsesV1"].Location, result.LoadedPath, ignoreCase: true); + Assert.AreEqual(AssemblyProvenance.AppLocal, result.Provenance); + Assert.IsNotNull(result.AppliedPolicy); + Assert.AreEqual(new Version(2, 0, 0, 0), result.AppliedPolicy!.BoundVersion); + Assert.AreEqual(oracle["SharedDep_via_UsesV1"].Location, result.LoadedPath, ignoreCase: true); } /// Two distinct requested versions of SharedDep collapse to the same loaded /// identity, proving the LoadedAssemblyCache interns by bound identity. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_LoadedAssemblyCache_SharedDepV1AndV2_CollapseToOneInternedEntry() { SkipIfNotWindows(); @@ -94,14 +101,15 @@ public void Bind_LoadedAssemblyCache_SharedDepV1AndV2_CollapseToOneInternedEntry var v2 = new AssemblyRefInfo("NetFxBindingRedirects.Clr2.SharedDep", "2.0.0.0", "neutral", "e89d2d22dd26920d"); var r1 = NetFxBinder.Bind(v1, ctx); var r2 = NetFxBinder.Bind(v2, ctx); - Assert.NotNull(r1.Loaded); - Assert.NotNull(r2.Loaded); - Assert.Equal(r1.Loaded, r2.Loaded); - Assert.Equal(r1.LoadedPath, r2.LoadedPath); + Assert.IsNotNull(r1.Loaded); + Assert.IsNotNull(r2.Loaded); + Assert.AreEqual(r1.Loaded, r2.Loaded); + Assert.AreEqual(r1.LoadedPath, r2.LoadedPath); } /// CodeBase href is honored: resolves to external/Clr2.CodeBaseLib.dll. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_CodeBaseLib_ViaConfiguredCodeBase_ProvenanceIsCodeBase() { SkipIfNotWindows(); @@ -110,13 +118,14 @@ public void Bind_CodeBaseLib_ViaConfiguredCodeBase_ProvenanceIsCodeBase() var requested = new AssemblyRefInfo( "NetFxBindingRedirects.Clr2.CodeBaseLib", "2.0.0.0", "neutral", "d4a9fecb5ef90905"); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.CodeBase, result.Provenance); - Assert.Equal(oracle["NetFxBindingRedirects.Clr2.CodeBaseLib"].Location, result.LoadedPath, ignoreCase: true); + Assert.AreEqual(AssemblyProvenance.CodeBase, result.Provenance); + Assert.AreEqual(oracle["NetFxBindingRedirects.Clr2.CodeBaseLib"].Location, result.LoadedPath, ignoreCase: true); } /// Deliberately-broken codeBase fail-fasts as CodeBaseMissing with the /// configured href reported on the result, distinct from generic Unresolved. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_MissingCodeBase_FailsFastWithCodeBaseMissingProvenance() { SkipIfNotWindows(); @@ -125,13 +134,14 @@ public void Bind_MissingCodeBase_FailsFastWithCodeBaseMissingProvenance() var requested = new AssemblyRefInfo( "NetFxBindingRedirects.Clr2.MissingCodeBase", "9.9.9.9", "neutral", "0123456789abcdef"); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.CodeBaseMissing, result.Provenance); - Assert.NotNull(result.AppliedPolicy); - Assert.Equal("external/Missing.dll", result.AppliedPolicy!.CodeBaseHref); + Assert.AreEqual(AssemblyProvenance.CodeBaseMissing, result.Provenance); + Assert.IsNotNull(result.AppliedPolicy); + Assert.AreEqual("external/Missing.dll", result.AppliedPolicy!.CodeBaseHref); } /// PrivatePathLib resolves from the lib/ subdir via probing privatePath. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_PrivatePathLib_FromProbingPrivatePath_LandsInLibSubdir() { SkipIfNotWindows(); @@ -140,8 +150,8 @@ public void Bind_PrivatePathLib_FromProbingPrivatePath_LandsInLibSubdir() var requested = new AssemblyRefInfo( "NetFxBindingRedirects.Clr2.PrivatePathLib", "1.0.0.0", "neutral", null); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.AppLocal, result.Provenance); - Assert.Equal(oracle["NetFxBindingRedirects.Clr2.PrivatePathLib"].Location, result.LoadedPath, ignoreCase: true); + Assert.AreEqual(AssemblyProvenance.AppLocal, result.Provenance); + Assert.AreEqual(oracle["NetFxBindingRedirects.Clr2.PrivatePathLib"].Location, result.LoadedPath, ignoreCase: true); Assert.Contains(Path.Combine("lib", "NetFxBindingRedirects.Clr2.PrivatePathLib.dll"), result.LoadedPath!, StringComparison.OrdinalIgnoreCase); } @@ -151,7 +161,8 @@ public void Bind_PrivatePathLib_FromProbingPrivatePath_LandsInLibSubdir() /// fr/NetFxBindingRedirects.Clr2.CulturedLib.resources.dll with the identity the /// CLR 2 runtime actually loaded (per oracle). /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_CulturedLibFrSatellite_FromCultureSubdir_MatchesOracleIdentity() { SkipIfNotWindows(); @@ -160,9 +171,9 @@ public void Bind_CulturedLibFrSatellite_FromCultureSubdir_MatchesOracleIdentity( var requested = new AssemblyRefInfo( "NetFxBindingRedirects.Clr2.CulturedLib.resources", "1.0.0.0", "fr", null); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.AppLocal, result.Provenance); + Assert.AreEqual(AssemblyProvenance.AppLocal, result.Provenance); var oracleEntry = oracle["NetFxBindingRedirects.Clr2.CulturedLib.resources(fr)"]; - Assert.Equal(oracleEntry.Location, result.LoadedPath, ignoreCase: true); + Assert.AreEqual(oracleEntry.Location, result.LoadedPath, ignoreCase: true); } // ---- Synthetic temp-tree tests (NOT gated on Clr2RuntimePresent) ---- @@ -174,7 +185,8 @@ public void Bind_CulturedLibFrSatellite_FromCultureSubdir_MatchesOracleIdentity( /// GacRoots, and asserts NetFxBinder.Bind finds the file via the bare /// GAC bucket. Independent of any host-installed file. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_TempGac_BareGacBucket_Clr2_FindsFileWithGacProvenance() { SkipIfNotWindows(); @@ -194,12 +206,13 @@ public void Bind_TempGac_BareGacBucket_Clr2_FindsFileWithGacProvenance() var requested = new AssemblyRefInfo(name, asmName.Version!.ToString(), "neutral", pkt); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.Gac, result.Provenance); - Assert.Equal(stagedDll, result.LoadedPath, ignoreCase: true); + Assert.AreEqual(AssemblyProvenance.Gac, result.Provenance); + Assert.AreEqual(stagedDll, result.LoadedPath, ignoreCase: true); } /// Same coverage as bare-GAC, but staged in GAC_MSIL. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_TempGac_GacMsilBucket_Clr2_FindsFile() { SkipIfNotWindows(); @@ -207,7 +220,8 @@ public void Bind_TempGac_GacMsilBucket_Clr2_FindsFile() } /// Same coverage as bare-GAC, but staged in GAC_64 (architecture slot). - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_TempGac_Gac64Bucket_Clr2_FindsFile() { SkipIfNotWindows(); @@ -220,7 +234,8 @@ public void Bind_TempGac_Gac64Bucket_Clr2_FindsFile() /// unification table. Validated indirectly: a v4.0_-prefixed dir under a Clr2 GAC does not /// produce a hit, while the no-prefix dir does. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_TempGac_V4PrefixedToken_NotHonoredInClr2Context() { SkipIfNotWindows(); @@ -241,27 +256,28 @@ public void Bind_TempGac_V4PrefixedToken_NotHonoredInClr2Context() // Clr2 binder builds the no-prefix token; the v4-prefixed dir doesn't match. The bind // falls through every path and ends Unresolved (no app-local fallback in temp setup). - Assert.NotEqual(AssemblyProvenance.Gac, result.Provenance); + Assert.AreNotEqual(AssemblyProvenance.Gac, result.Provenance); } /// Optional opportunistic coverage: if the host has stdole 7.0.3300.0 at /// %WINDIR%\assembly\GAC\stdole\7.0.3300.0__b03f5f7f11d50a3a\stdole.dll, assert the /// production GAC bare-GAC bucket is reachable. Skips cleanly otherwise. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_StdOle_FromBareGac_WhenPresent_FindsViaGac() { SkipIfNotWindows(); var windir = Environment.GetEnvironmentVariable("WINDIR")!; var stdolePath = Path.Combine(windir, "assembly", "GAC", "stdole", "7.0.3300.0__b03f5f7f11d50a3a", "stdole.dll"); if (!File.Exists(stdolePath)) - Assert.Skip("stdole not present at the canonical bare-GAC path on this host."); + Assert.Inconclusive("stdole not present at the canonical bare-GAC path on this host."); SkipIfClr2Absent(); var (ctx, _) = LoadFixture(); var requested = new AssemblyRefInfo("stdole", "7.0.3300.0", "neutral", "b03f5f7f11d50a3a"); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.Gac, result.Provenance); - Assert.Equal(stdolePath, result.LoadedPath, ignoreCase: true); + Assert.AreEqual(AssemblyProvenance.Gac, result.Provenance); + Assert.AreEqual(stdolePath, result.LoadedPath, ignoreCase: true); } /// @@ -269,7 +285,8 @@ public void Bind_StdOle_FromBareGac_WhenPresent_FindsViaGac() /// appliesTo="v2.0.50727" publisher policy fires, while a parallel block scoped /// appliesTo="v4.0.30319" is correctly excluded. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PublisherPolicy_TempClr2Gac_AppliesToV2_FiresAndAppliesToV4_DoesNot() { SkipIfNotWindows(); @@ -306,10 +323,10 @@ public void PublisherPolicy_TempClr2Gac_AppliesToV2_FiresAndAppliesToV4_DoesNot( var ctx = MakeSyntheticClr2Context(gacRoot: gacRoot); var v2Redirects = ctx.Policy.PublisherPolicyRedirects; - Assert.Contains(v2Redirects, r => - r.Name == "Acme.Synthetic" && r.NewVersion == new Version(3, 0, 0, 0)); - Assert.DoesNotContain(v2Redirects, r => - r.Name == "Acme.Synthetic" && r.NewVersion == new Version(9, 9, 9, 9)); + Assert.Contains(r => + r.Name == "Acme.Synthetic" && r.NewVersion == new Version(3, 0, 0, 0), v2Redirects); + Assert.DoesNotContain(r => + r.Name == "Acme.Synthetic" && r.NewVersion == new Version(9, 9, 9, 9), v2Redirects); } // ---- Helpers ---- @@ -332,8 +349,8 @@ private static void AssertClr2GacBucketHit(string bucket) var requested = new AssemblyRefInfo(name, asmName.Version!.ToString(), "neutral", pkt); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.Gac, result.Provenance); - Assert.Equal(stagedDll, result.LoadedPath, ignoreCase: true); + Assert.AreEqual(AssemblyProvenance.Gac, result.Provenance); + Assert.AreEqual(stagedDll, result.LoadedPath, ignoreCase: true); } private static string SignedSampleDll() @@ -343,7 +360,7 @@ private static string SignedSampleDll() var repoRoot = TestHelpers.GetRepoRoot(); var dll = Path.Combine(repoRoot, "samples", "NetFxBindingRedirects.Clr2.CodeBaseLib", "bin", "Debug", "net35", "NetFxBindingRedirects.Clr2.CodeBaseLib.dll"); - Assert.True(File.Exists(dll), $"Signed sample DLL not built at {dll}"); + Assert.IsTrue(File.Exists(dll), $"Signed sample DLL not built at {dll}"); return dll; } @@ -373,26 +390,26 @@ private static NetFxBindingContext MakeSyntheticClr2Context(string gacRoot) return Convert.ToHexStringLower(token); } - private (NetFxBindingContext Ctx, IReadOnlyDictionary Oracle) LoadFixture() + private static (NetFxBindingContext Ctx, IReadOnlyDictionary Oracle) LoadFixture() { - Assert.NotNull(samples.NetFxBindingRedirectsClr2Exe); - Assert.NotNull(samples.NetFxBindingRedirectsClr2Oracle); - var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsClr2Exe!); + Assert.IsNotNull(Samples.NetFxBindingRedirectsClr2Exe); + Assert.IsNotNull(Samples.NetFxBindingRedirectsClr2Oracle); + var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsClr2Exe!); var ctx = NetFxBindingContext.TryBuild(analyzer); - Assert.NotNull(ctx); - return (ctx!, samples.NetFxBindingRedirectsClr2Oracle!); + Assert.IsNotNull(ctx); + return (ctx!, Samples.NetFxBindingRedirectsClr2Oracle!); } private static void SkipIfNotWindows() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - Assert.Skip("Test requires Windows (.NET Framework binder)."); + Assert.Inconclusive("Test requires Windows (.NET Framework binder)."); } - private void SkipIfClr2Absent() + private static void SkipIfClr2Absent() { - if (!samples.Clr2RuntimePresent) - Assert.Skip("CLR 2 runtime not installed on this host (no v2.0.50727 mscorlib)."); + if (!Samples.Clr2RuntimePresent) + Assert.Inconclusive("CLR 2 runtime not installed on this host (no v2.0.50727 mscorlib)."); } private sealed class TempDir : IDisposable diff --git a/tests/Dotsider.Tests/NetFxBinderOracleClr2Tests.cs b/tests/Dotsider.Tests/NetFxBinderOracleClr2Tests.cs index 9fd19ab2..ccedc4cb 100644 --- a/tests/Dotsider.Tests/NetFxBinderOracleClr2Tests.cs +++ b/tests/Dotsider.Tests/NetFxBinderOracleClr2Tests.cs @@ -12,23 +12,26 @@ namespace Dotsider.Tests; /// . Any divergence is a binder bug. Mirror of /// for the Clr2 path. /// -[Collection("SampleAssemblies")] -public sealed class NetFxBinderOracleClr2Tests(SampleAssemblyFixture samples) +[TestClass] +public sealed class NetFxBinderOracleClr2Tests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// For every successful oracle entry, dotsider's binder produces a matching loaded /// identity at the same on-disk location. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Oracle_AllNonRootRefs_DotsiderBindResultMatches() { SkipIfNotWindows(); SkipIfOracleAbsent(); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsClr2Exe!); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsClr2Exe!); var ctx = NetFxBindingContext.TryBuild(analyzer); - Assert.NotNull(ctx); - Assert.Equal(NetFxRuntimeVersion.Clr2, ctx!.RuntimeVersion); + Assert.IsNotNull(ctx); + Assert.AreEqual(NetFxRuntimeVersion.Clr2, ctx!.RuntimeVersion); - foreach (var (key, entry) in samples.NetFxBindingRedirectsClr2Oracle!) + foreach (var (key, entry) in Samples.NetFxBindingRedirectsClr2Oracle!) { // Skip the deliberately-broken codeBase entry — covered by the negative test below. if (key == "NetFxBindingRedirects.Clr2.MissingCodeBase") continue; @@ -43,30 +46,33 @@ public void Oracle_AllNonRootRefs_DotsiderBindResultMatches() var bind = NetFxBinder.Bind(requested, ctx); - Assert.NotNull(bind.Loaded); - Assert.Equal(asmName.Name, bind.Loaded!.Name); - Assert.Equal(asmName.Version?.ToString(), bind.Loaded.Version); - Assert.NotNull(bind.LoadedPath); - Assert.Equal(entry.Location, bind.LoadedPath, ignoreCase: true); + Assert.IsNotNull(bind.Loaded); + Assert.AreEqual(asmName.Name, bind.Loaded!.Name); + Assert.IsNotNull(asmName.Version); + Assert.IsNotNull(bind.Loaded.Version); + Assert.AreEqual(asmName.Version.ToString(), bind.Loaded.Version); + Assert.IsNotNull(bind.LoadedPath); + Assert.AreEqual(entry.Location, bind.LoadedPath, ignoreCase: true); } } /// The deliberately-broken codeBase entry stays a hard miss for both oracle and binder. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Oracle_MissingCodeBase_BothOracleAndBinderReportMiss() { SkipIfNotWindows(); SkipIfOracleAbsent(); - var entry = samples.NetFxBindingRedirectsClr2Oracle!["NetFxBindingRedirects.Clr2.MissingCodeBase"]; - Assert.False(entry.Loaded); + var entry = Samples.NetFxBindingRedirectsClr2Oracle!["NetFxBindingRedirects.Clr2.MissingCodeBase"]; + Assert.IsFalse(entry.Loaded); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsClr2Exe!); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsClr2Exe!); var ctx = NetFxBindingContext.TryBuild(analyzer); var requested = new AssemblyRefInfo( "NetFxBindingRedirects.Clr2.MissingCodeBase", "9.9.9.9", "neutral", "0123456789abcdef"); var bind = NetFxBinder.Bind(requested, ctx!); - Assert.Equal(AssemblyProvenance.CodeBaseMissing, bind.Provenance); + Assert.AreEqual(AssemblyProvenance.CodeBaseMissing, bind.Provenance); } private static string? HexFormatToken(byte[]? token) @@ -78,12 +84,12 @@ public void Oracle_MissingCodeBase_BothOracleAndBinderReportMiss() private static void SkipIfNotWindows() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - Assert.Skip("Test requires Windows (.NET Framework binder)."); + Assert.Inconclusive("Test requires Windows (.NET Framework binder)."); } - private void SkipIfOracleAbsent() + private static void SkipIfOracleAbsent() { - if (samples.NetFxBindingRedirectsClr2Oracle is null) - Assert.Skip("CLR 2 oracle was not captured (CLR 2 runtime not installed on this host)."); + if (Samples.NetFxBindingRedirectsClr2Oracle is null) + Assert.Inconclusive("CLR 2 oracle was not captured (CLR 2 runtime not installed on this host)."); } } diff --git a/tests/Dotsider.Tests/NetFxBinderOracleTests.cs b/tests/Dotsider.Tests/NetFxBinderOracleTests.cs index 98d764f7..bc4046e1 100644 --- a/tests/Dotsider.Tests/NetFxBinderOracleTests.cs +++ b/tests/Dotsider.Tests/NetFxBinderOracleTests.cs @@ -11,25 +11,28 @@ namespace Dotsider.Tests; /// matching identity and . /// Any divergence is a binder bug, not a documentation note. This is the literal-accuracy gate. /// -[Collection("SampleAssemblies")] -public sealed class NetFxBinderOracleTests(SampleAssemblyFixture samples) +[TestClass] +public sealed class NetFxBinderOracleTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// For every successful oracle entry, dotsider's binder produces a matching loaded /// identity at the same on-disk location. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Oracle_AllNonRootRefs_DotsiderBindResultMatches() { SkipIfNotWindows(); - Assert.NotNull(samples.NetFxBindingRedirectsExe); - Assert.NotNull(samples.NetFxBindingRedirectsOracle); + Assert.IsNotNull(Samples.NetFxBindingRedirectsExe); + Assert.IsNotNull(Samples.NetFxBindingRedirectsOracle); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsExe!); var ctx = NetFxBindingContext.TryBuild(analyzer); - Assert.NotNull(ctx); + Assert.IsNotNull(ctx); - foreach (var (key, entry) in samples.NetFxBindingRedirectsOracle!) + foreach (var (key, entry) in Samples.NetFxBindingRedirectsOracle!) { // Skip the deliberately-broken codeBase entry — that's covered by the negative test. if (key == "NetFxBindingRedirects.MissingCodeBase") continue; @@ -44,29 +47,32 @@ public void Oracle_AllNonRootRefs_DotsiderBindResultMatches() PublicKeyToken: HexFormatToken(asmName.GetPublicKeyToken())); var bind = NetFxBinder.Bind(requested, ctx!); - Assert.NotNull(bind.Loaded); - Assert.Equal(asmName.Name, bind.Loaded!.Name); - Assert.Equal(asmName.Version?.ToString(), bind.Loaded.Version); - Assert.NotNull(bind.LoadedPath); - Assert.Equal(entry.Location, bind.LoadedPath, ignoreCase: true); + Assert.IsNotNull(bind.Loaded); + Assert.AreEqual(asmName.Name, bind.Loaded!.Name); + Assert.IsNotNull(asmName.Version); + Assert.IsNotNull(bind.Loaded.Version); + Assert.AreEqual(asmName.Version.ToString(), bind.Loaded.Version); + Assert.IsNotNull(bind.LoadedPath); + Assert.AreEqual(entry.Location, bind.LoadedPath, ignoreCase: true); } } /// The deliberately-broken codeBase entry stays a hard miss for both oracle and binder. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Oracle_MissingCodeBase_BothOracleAndBinderReportMiss() { SkipIfNotWindows(); - Assert.NotNull(samples.NetFxBindingRedirectsOracle); - var entry = samples.NetFxBindingRedirectsOracle!["NetFxBindingRedirects.MissingCodeBase"]; - Assert.False(entry.Loaded); + Assert.IsNotNull(Samples.NetFxBindingRedirectsOracle); + var entry = Samples.NetFxBindingRedirectsOracle!["NetFxBindingRedirects.MissingCodeBase"]; + Assert.IsFalse(entry.Loaded); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsExe!); var ctx = NetFxBindingContext.TryBuild(analyzer); var requested = new AssemblyRefInfo( "NetFxBindingRedirects.MissingCodeBase", "9.9.9.9", "neutral", "0123456789abcdef"); var bind = NetFxBinder.Bind(requested, ctx!); - Assert.Equal(AssemblyProvenance.CodeBaseMissing, bind.Provenance); + Assert.AreEqual(AssemblyProvenance.CodeBaseMissing, bind.Provenance); } private static string? HexFormatToken(byte[]? token) @@ -78,6 +84,6 @@ public void Oracle_MissingCodeBase_BothOracleAndBinderReportMiss() private static void SkipIfNotWindows() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - Assert.Skip("Test requires Windows (.NET Framework binder)."); + Assert.Inconclusive("Test requires Windows (.NET Framework binder)."); } } diff --git a/tests/Dotsider.Tests/NetFxBinderTests.cs b/tests/Dotsider.Tests/NetFxBinderTests.cs index 7a87e0eb..e622ba00 100644 --- a/tests/Dotsider.Tests/NetFxBinderTests.cs +++ b/tests/Dotsider.Tests/NetFxBinderTests.cs @@ -10,57 +10,63 @@ namespace Dotsider.Tests; /// binder behavior — every assertion compares dotsider's bind result to a real on-disk /// file the actual .NET Framework CLR would also load. /// -[Collection("SampleAssemblies")] -public sealed class NetFxBinderTests(SampleAssemblyFixture samples) +[TestClass] +public sealed class NetFxBinderTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// mscorlib resolves from the .NET Framework runtime directory. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_Mscorlib_FromFrameworkRuntimeDirectory_MatchesOracle() { SkipIfNotWindows(); var (ctx, oracle) = LoadFixture(); var requested = new AssemblyRefInfo("mscorlib", "4.0.0.0", "neutral", "b77a5c561934e089"); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.FrameworkRuntimeDirectory, result.Provenance); - Assert.NotNull(result.LoadedPath); - Assert.Equal( + Assert.AreEqual(AssemblyProvenance.FrameworkRuntimeDirectory, result.Provenance); + Assert.IsNotNull(result.LoadedPath); + Assert.AreEqual( oracle["mscorlib"].Location, result.LoadedPath, ignoreCase: true); } /// System.Drawing resolves from the GAC. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_SystemDrawing_ResolvesViaGacOrRuntimeDirectoryPerOracle() { SkipIfNotWindows(); var (ctx, oracle) = LoadFixture(); var requested = new AssemblyRefInfo("System.Drawing", "4.0.0.0", "neutral", "b03f5f7f11d50a3a"); var result = NetFxBinder.Bind(requested, ctx); - Assert.True(result.Provenance is AssemblyProvenance.Gac or AssemblyProvenance.FrameworkRuntimeDirectory, + Assert.IsTrue(result.Provenance is AssemblyProvenance.Gac or AssemblyProvenance.FrameworkRuntimeDirectory, $"Expected Gac or FrameworkRuntimeDirectory, got {result.Provenance}"); - Assert.Equal(oracle["System.Drawing"].Location, result.LoadedPath, ignoreCase: true); + Assert.AreEqual(oracle["System.Drawing"].Location, result.LoadedPath, ignoreCase: true); } /// Newtonsoft.Json 12 redirects to 13 and resolves from app-local with the /// AppliedPolicy annotation populated. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_NewtonsoftJson_RedirectAppliedAndAppLocalHit_MatchesOracleVersionAndPath() { SkipIfNotWindows(); var (ctx, oracle) = LoadFixture(); var requested = new AssemblyRefInfo("Newtonsoft.Json", "12.0.0.0", "neutral", "30ad4fe6b2a6aeed"); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.AppLocal, result.Provenance); - Assert.NotNull(result.AppliedPolicy); - Assert.Equal(new Version(13, 0, 0, 0), result.AppliedPolicy!.BoundVersion); - Assert.Equal(oracle["Newtonsoft.Json"].Location, result.LoadedPath, ignoreCase: true); + Assert.AreEqual(AssemblyProvenance.AppLocal, result.Provenance); + Assert.IsNotNull(result.AppliedPolicy); + Assert.AreEqual(new Version(13, 0, 0, 0), result.AppliedPolicy!.BoundVersion); + Assert.AreEqual(oracle["Newtonsoft.Json"].Location, result.LoadedPath, ignoreCase: true); } /// Two distinct requested versions of Newtonsoft.Json collapse to the same /// LoadedAssemblyEntry (reference-equal), proving the LoadedAssemblyCache interns by /// bound identity. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_LoadedAssemblyCache_TwoDistinctRequestsCollapseToOneInternedLoadedEntry() { SkipIfNotWindows(); @@ -70,14 +76,15 @@ public void Bind_LoadedAssemblyCache_TwoDistinctRequestsCollapseToOneInternedLoa var v13 = new AssemblyRefInfo("Newtonsoft.Json", "13.0.0.0", "neutral", "30ad4fe6b2a6aeed"); var r12 = NetFxBinder.Bind(v12, ctx); var r13 = NetFxBinder.Bind(v13, ctx); - Assert.NotNull(r12.Loaded); - Assert.NotNull(r13.Loaded); - Assert.Equal(r12.Loaded, r13.Loaded); - Assert.Equal(r12.LoadedPath, r13.LoadedPath); + Assert.IsNotNull(r12.Loaded); + Assert.IsNotNull(r13.Loaded); + Assert.AreEqual(r12.Loaded, r13.Loaded); + Assert.AreEqual(r12.LoadedPath, r13.LoadedPath); } /// Repeated bind requests for the same identity hit the cache; no extra probes. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_RequestedBindCache_RepeatedRequest_NoSecondFilesystemTraversal() { SkipIfNotWindows(); @@ -87,11 +94,12 @@ public void Bind_RequestedBindCache_RepeatedRequest_NoSecondFilesystemTraversal( NetFxBinder.Bind(requested, ctx); var probesAfterFirst = NetFxBinder.GetProbeCount(ctx); NetFxBinder.Bind(requested, ctx); - Assert.Equal(probesAfterFirst, NetFxBinder.GetProbeCount(ctx)); + Assert.AreEqual(probesAfterFirst, NetFxBinder.GetProbeCount(ctx)); } /// Failures are cached too — known misses don't re-probe. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_RequestedBindCache_FailureCached_DoesNotReprobe() { SkipIfNotWindows(); @@ -101,11 +109,12 @@ public void Bind_RequestedBindCache_FailureCached_DoesNotReprobe() NetFxBinder.Bind(nonExistent, ctx); var afterFirst = NetFxBinder.GetProbeCount(ctx); NetFxBinder.Bind(nonExistent, ctx); - Assert.Equal(afterFirst, NetFxBinder.GetProbeCount(ctx)); + Assert.AreEqual(afterFirst, NetFxBinder.GetProbeCount(ctx)); } /// Configured codeBase href resolves to the file under external/. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_CodeBaseLib_ResolvedViaCodeBaseHref() { SkipIfNotWindows(); @@ -113,9 +122,9 @@ public void Bind_CodeBaseLib_ResolvedViaCodeBaseHref() var requested = new AssemblyRefInfo( "NetFxBindingRedirects.CodeBaseLib", "2.0.0.0", "neutral", "e061e779022b0ce6"); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.CodeBase, result.Provenance); - Assert.NotNull(result.LoadedPath); - Assert.Equal( + Assert.AreEqual(AssemblyProvenance.CodeBase, result.Provenance); + Assert.IsNotNull(result.LoadedPath); + Assert.AreEqual( oracle["NetFxBindingRedirects.CodeBaseLib"].Location, result.LoadedPath, ignoreCase: true); @@ -123,7 +132,8 @@ public void Bind_CodeBaseLib_ResolvedViaCodeBaseHref() /// A codeBase whose href doesn't exist fails fast with CodeBaseMissing /// rather than falling through to probing. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_CodeBaseMissing_FailFast_DoesNotFallBackToProbing() { SkipIfNotWindows(); @@ -131,13 +141,14 @@ public void Bind_CodeBaseMissing_FailFast_DoesNotFallBackToProbing() var requested = new AssemblyRefInfo( "NetFxBindingRedirects.MissingCodeBase", "9.9.9.9", "neutral", "0123456789abcdef"); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.CodeBaseMissing, result.Provenance); - Assert.Null(result.LoadedPath); + Assert.AreEqual(AssemblyProvenance.CodeBaseMissing, result.Provenance); + Assert.IsNull(result.LoadedPath); Assert.Contains("Missing.dll", result.FailureReason ?? string.Empty, StringComparison.OrdinalIgnoreCase); } /// privatePath probing is rooted at the application base, not the parent DLL. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_PrivatePathLib_ResolvedFromAppBaseLibSubdir() { SkipIfNotWindows(); @@ -145,9 +156,9 @@ public void Bind_PrivatePathLib_ResolvedFromAppBaseLibSubdir() var requested = new AssemblyRefInfo( "NetFxBindingRedirects.PrivatePathLib", "1.0.0.0", "neutral", null); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.AppLocal, result.Provenance); - Assert.NotNull(result.LoadedPath); - Assert.Equal( + Assert.AreEqual(AssemblyProvenance.AppLocal, result.Provenance); + Assert.IsNotNull(result.LoadedPath); + Assert.AreEqual( oracle["NetFxBindingRedirects.PrivatePathLib"].Location, result.LoadedPath, ignoreCase: true); @@ -160,7 +171,8 @@ public void Bind_PrivatePathLib_ResolvedFromAppBaseLibSubdir() /// and so the UI can surface the broken href /// without having to dig into the failure reason string. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_CodeBaseMissing_PreservesHrefForDiagnostics() { SkipIfNotWindows(); @@ -168,11 +180,11 @@ public void Bind_CodeBaseMissing_PreservesHrefForDiagnostics() var requested = new AssemblyRefInfo( "NetFxBindingRedirects.MissingCodeBase", "9.9.9.9", "neutral", "0123456789abcdef"); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.CodeBaseMissing, result.Provenance); - Assert.NotNull(result.AppliedPolicy); - Assert.NotNull(result.AppliedPolicy!.CodeBaseHref); + Assert.AreEqual(AssemblyProvenance.CodeBaseMissing, result.Provenance); + Assert.IsNotNull(result.AppliedPolicy); + Assert.IsNotNull(result.AppliedPolicy!.CodeBaseHref); Assert.EndsWith("Missing.dll", result.AppliedPolicy.CodeBaseHref!, StringComparison.OrdinalIgnoreCase); - Assert.NotNull(result.CandidateProbePath); + Assert.IsNotNull(result.CandidateProbePath); Assert.EndsWith("Missing.dll", result.CandidateProbePath!, StringComparison.OrdinalIgnoreCase); } @@ -181,17 +193,18 @@ public void Bind_CodeBaseMissing_PreservesHrefForDiagnostics() /// framework: only well-known Microsoft framework PKTs qualify under /// AssemblyAnalyzer.IsFrameworkAssembly. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void IsFrameworkAssembly_GacProvenance_OnlyTrueForWellKnownFrameworkPkt() { // Microsoft framework PKT b77a5c561934e089 → framework. var msIdentity = new AssemblyRefInfo("Anything", "1.0.0.0", "neutral", "b77a5c561934e089"); - Assert.True(AssemblyAnalyzer.IsFrameworkAssembly( + Assert.IsTrue(AssemblyAnalyzer.IsFrameworkAssembly( AssemblyProvenance.Gac, msIdentity, ".NETFramework,Version=v4.8", null)); // Third-party strong-named lib: random PKT → not framework, even when in the GAC. var thirdParty = new AssemblyRefInfo("Vendor.Lib", "1.0.0.0", "neutral", "0123456789abcdef"); - Assert.False(AssemblyAnalyzer.IsFrameworkAssembly( + Assert.IsFalse(AssemblyAnalyzer.IsFrameworkAssembly( AssemblyProvenance.Gac, thirdParty, ".NETFramework,Version=v4.8", null)); } @@ -202,7 +215,8 @@ public void IsFrameworkAssembly_GacProvenance_OnlyTrueForWellKnownFrameworkPkt() /// failure reason names the original UNC href, proving the resolver kept the prefix and /// hit a real file probe (which then missed) instead of silently misclassifying. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_CodeBaseHref_UncFileUri_PreservesUncPrefix() { SkipIfNotWindows(); @@ -234,10 +248,10 @@ public void Bind_CodeBaseHref_UncFileUri_PreservesUncPrefix() var requested = new AssemblyRefInfo("Acme.UncLib", "1.0.0.0", "neutral", "1111111111111111"); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.CodeBaseMissing, result.Provenance); - Assert.NotNull(result.AppliedPolicy); - Assert.Equal(codeBase.Href, result.AppliedPolicy!.CodeBaseHref); - Assert.Equal(codeBase.Href, result.CandidateProbePath); + Assert.AreEqual(AssemblyProvenance.CodeBaseMissing, result.Provenance); + Assert.IsNotNull(result.AppliedPolicy); + Assert.AreEqual(codeBase.Href, result.AppliedPolicy!.CodeBaseHref); + Assert.AreEqual(codeBase.Href, result.CandidateProbePath); } /// @@ -247,22 +261,23 @@ public void Bind_CodeBaseHref_UncFileUri_PreservesUncPrefix() /// slot v4.0_10.0.0.0__b03f5f7f11d50a3a. Provenance is , /// not the framework runtime directory, so the location dotsider reports matches the CLR. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_FrameworkUnification_MicrosoftVisualBasicV8_UnifiesAndLoadsFromGac() { SkipIfNotWindows(); var (ctx, _) = LoadFixture(); var requested = new AssemblyRefInfo("Microsoft.VisualBasic", "8.0.0.0", "neutral", "b03f5f7f11d50a3a"); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.Gac, result.Provenance); - Assert.NotNull(result.Loaded); - Assert.Equal("10.0.0.0", result.Loaded!.Version); - Assert.NotNull(result.LoadedPath); + Assert.AreEqual(AssemblyProvenance.Gac, result.Provenance); + Assert.IsNotNull(result.Loaded); + Assert.AreEqual("10.0.0.0", result.Loaded!.Version); + Assert.IsNotNull(result.LoadedPath); Assert.Contains("GAC_MSIL", result.LoadedPath, StringComparison.OrdinalIgnoreCase); - Assert.NotNull(result.AppliedPolicy); - Assert.Equal(PolicyLayer.FrameworkUnification, result.AppliedPolicy!.Source); - Assert.Equal(new Version(8, 0, 0, 0), result.AppliedPolicy.RequestedVersion); - Assert.Equal(new Version(10, 0, 0, 0), result.AppliedPolicy.BoundVersion); + Assert.IsNotNull(result.AppliedPolicy); + Assert.AreEqual(PolicyLayer.FrameworkUnification, result.AppliedPolicy!.Source); + Assert.AreEqual(new Version(8, 0, 0, 0), result.AppliedPolicy.RequestedVersion); + Assert.AreEqual(new Version(10, 0, 0, 0), result.AppliedPolicy.BoundVersion); } /// @@ -272,18 +287,19 @@ public void Bind_FrameworkUnification_MicrosoftVisualBasicV8_UnifiesAndLoadsFrom /// v4.0.0.0 file must fail without an explicit binding redirect — same as the real /// runtime — rather than silently rolling forward. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_CompatibilityPackPkt_DoesNotUnify() { SkipIfNotWindows(); var (ctx, _) = LoadFixture(); var requested = new AssemblyRefInfo("System.ValueTuple", "4.1.0.0", "neutral", "cc7b13ffcd2ddd51"); var result = NetFxBinder.Bind(requested, ctx); - Assert.NotEqual(AssemblyProvenance.Gac, result.Provenance); - Assert.NotEqual(AssemblyProvenance.FrameworkRuntimeDirectory, result.Provenance); + Assert.AreNotEqual(AssemblyProvenance.Gac, result.Provenance); + Assert.AreNotEqual(AssemblyProvenance.FrameworkRuntimeDirectory, result.Provenance); // The framework unification layer must not have rewritten the version. if (result.AppliedPolicy is not null) - Assert.NotEqual(PolicyLayer.FrameworkUnification, result.AppliedPolicy.Source); + Assert.AreNotEqual(PolicyLayer.FrameworkUnification, result.AppliedPolicy.Source); } /// @@ -293,22 +309,23 @@ public void Bind_CompatibilityPackPkt_DoesNotUnify() /// requested version is higher than the runtime's. The unification table rewrites the /// effective identity, then the GAC scan finds the file at its real slot. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_FrameworkUnification_HigherThanFramework_RollsDownToInBoxVersion() { SkipIfNotWindows(); var (ctx, _) = LoadFixture(); var requested = new AssemblyRefInfo("System.IO.Compression", "4.2.0.0", "neutral", "b77a5c561934e089"); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.Gac, result.Provenance); - Assert.NotNull(result.Loaded); - Assert.Equal("4.0.0.0", result.Loaded!.Version); - Assert.NotNull(result.LoadedPath); + Assert.AreEqual(AssemblyProvenance.Gac, result.Provenance); + Assert.IsNotNull(result.Loaded); + Assert.AreEqual("4.0.0.0", result.Loaded!.Version); + Assert.IsNotNull(result.LoadedPath); Assert.Contains("GAC_MSIL", result.LoadedPath, StringComparison.OrdinalIgnoreCase); - Assert.NotNull(result.AppliedPolicy); - Assert.Equal(PolicyLayer.FrameworkUnification, result.AppliedPolicy!.Source); - Assert.Equal(new Version(4, 2, 0, 0), result.AppliedPolicy.RequestedVersion); - Assert.Equal(new Version(4, 0, 0, 0), result.AppliedPolicy.BoundVersion); + Assert.IsNotNull(result.AppliedPolicy); + Assert.AreEqual(PolicyLayer.FrameworkUnification, result.AppliedPolicy!.Source); + Assert.AreEqual(new Version(4, 2, 0, 0), result.AppliedPolicy.RequestedVersion); + Assert.AreEqual(new Version(4, 0, 0, 0), result.AppliedPolicy.BoundVersion); } /// @@ -317,22 +334,23 @@ public void Bind_FrameworkUnification_HigherThanFramework_RollsDownToInBoxVersio /// net48 PowerShell. The mscorlib fast path runs after unification, so the effective /// identity already matches the in-box file and the strict identity check passes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_FrameworkUnification_MscorlibV8_LoadsFrameworkV4() { SkipIfNotWindows(); var (ctx, _) = LoadFixture(); var requested = new AssemblyRefInfo("mscorlib", "8.0.0.0", "neutral", "b77a5c561934e089"); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.FrameworkRuntimeDirectory, result.Provenance); - Assert.NotNull(result.Loaded); - Assert.Equal("4.0.0.0", result.Loaded!.Version); - Assert.NotNull(result.LoadedPath); + Assert.AreEqual(AssemblyProvenance.FrameworkRuntimeDirectory, result.Provenance); + Assert.IsNotNull(result.Loaded); + Assert.AreEqual("4.0.0.0", result.Loaded!.Version); + Assert.IsNotNull(result.LoadedPath); Assert.Contains("v4.0.30319", result.LoadedPath, StringComparison.OrdinalIgnoreCase); - Assert.NotNull(result.AppliedPolicy); - Assert.Equal(PolicyLayer.FrameworkUnification, result.AppliedPolicy!.Source); - Assert.Equal(new Version(8, 0, 0, 0), result.AppliedPolicy.RequestedVersion); - Assert.Equal(new Version(4, 0, 0, 0), result.AppliedPolicy.BoundVersion); + Assert.IsNotNull(result.AppliedPolicy); + Assert.AreEqual(PolicyLayer.FrameworkUnification, result.AppliedPolicy!.Source); + Assert.AreEqual(new Version(8, 0, 0, 0), result.AppliedPolicy.RequestedVersion); + Assert.AreEqual(new Version(4, 0, 0, 0), result.AppliedPolicy.BoundVersion); } /// @@ -342,21 +360,22 @@ public void Bind_FrameworkUnification_MscorlibV8_LoadsFrameworkV4() /// C:\Windows\assembly\GAC\stdole\7.0.3300.0__b03f5f7f11d50a3a\stdole.dll. Token /// format there has no v4.0_ prefix. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_LegacyGac_StdoleResolvesFromWindowsAssemblyGac() { SkipIfNotWindows(); // Skip if the test box doesn't have stdole installed in the legacy GAC. var stdolePath = @"C:\Windows\assembly\GAC\stdole\7.0.3300.0__b03f5f7f11d50a3a\stdole.dll"; if (!File.Exists(stdolePath)) - Assert.Skip("Legacy GAC entry for stdole 7.0.3300.0 is not present on this machine."); + Assert.Inconclusive("Legacy GAC entry for stdole 7.0.3300.0 is not present on this machine."); var (ctx, _) = LoadFixture(); var requested = new AssemblyRefInfo("stdole", "7.0.3300.0", "neutral", "b03f5f7f11d50a3a"); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.Gac, result.Provenance); - Assert.NotNull(result.LoadedPath); - Assert.Equal(stdolePath, result.LoadedPath, ignoreCase: true); + Assert.AreEqual(AssemblyProvenance.Gac, result.Provenance); + Assert.IsNotNull(result.LoadedPath); + Assert.AreEqual(stdolePath, result.LoadedPath, ignoreCase: true); } /// @@ -367,7 +386,8 @@ public void Bind_LegacyGac_StdoleResolvesFromWindowsAssemblyGac() /// for an assembly name that's known to live in the GAC under a framework PKT but isn't a /// framework assembly, and verify the binder doesn't unify it. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_FrameworkUnification_NonFrameworkGacEntries_DoNotUnify() { SkipIfNotWindows(); @@ -378,7 +398,7 @@ public void Bind_FrameworkUnification_NonFrameworkGacEntries_DoNotUnify() var requested = new AssemblyRefInfo("Microsoft.Build.Tasks.Core", "1.0.0.0", "neutral", "b03f5f7f11d50a3a"); var result = NetFxBinder.Bind(requested, ctx); if (result.AppliedPolicy is not null) - Assert.NotEqual(PolicyLayer.FrameworkUnification, result.AppliedPolicy.Source); + Assert.AreNotEqual(PolicyLayer.FrameworkUnification, result.AppliedPolicy.Source); } /// @@ -390,24 +410,25 @@ public void Bind_FrameworkUnification_NonFrameworkGacEntries_DoNotUnify() /// exercises GAC_64 explicitly. This test simply confirms a GAC_32-only entry isn't /// picked up on an Amd64 root. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_FrameworkUnification_RespectsRootArchitecture() { SkipIfNotWindows(); var (ctx, _) = LoadFixture(); - Assert.Equal(NetFxArchitecture.Amd64, ctx.EffectiveArchitecture); + Assert.AreEqual(NetFxArchitecture.Amd64, ctx.EffectiveArchitecture); // The unification table built for an Amd64 root must not contain entries that exist // only in GAC_32. We can't easily synthesize a GAC_32-only entry on this machine, but // we can assert the table's entries map to versions reachable from the locate stage — // i.e. every (name, pkt) in the table has either a Framework64 file or a GAC_64/MSIL // entry at the table's version. Sample-check System.Printing. - Assert.NotNull(ctx.Policy.FrameworkUnificationTable); + Assert.IsNotNull(ctx.Policy.FrameworkUnificationTable); var key = ("System.Printing", "31bf3856ad364e35"); - Assert.True( + Assert.IsTrue( ctx.Policy.FrameworkUnificationTable!.ContainsKey(key), "System.Printing should be in the unification table for an Amd64 root."); var v = ctx.Policy.FrameworkUnificationTable[key]; - Assert.True(File.Exists( + Assert.IsTrue(File.Exists( $@"C:\Windows\Microsoft.NET\assembly\GAC_64\System.Printing\v4.0_{v}__31bf3856ad364e35\System.Printing.dll") || File.Exists( $@"C:\Windows\Microsoft.NET\assembly\GAC_MSIL\System.Printing\v4.0_{v}__31bf3856ad364e35\System.Printing.dll"), @@ -421,24 +442,26 @@ public void Bind_FrameworkUnification_RespectsRootArchitecture() /// from GAC_64\System.Printing\v4.0_4.0.0.0__31bf3856ad364e35\. The unification /// table picks these up by walking the GAC alongside the framework runtime directory. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_FrameworkUnification_SystemPrintingV3_UnifiesViaGacEntry() { SkipIfNotWindows(); var (ctx, _) = LoadFixture(); var requested = new AssemblyRefInfo("System.Printing", "3.0.0.0", "neutral", "31bf3856ad364e35"); var result = NetFxBinder.Bind(requested, ctx); - Assert.Equal(AssemblyProvenance.Gac, result.Provenance); - Assert.NotNull(result.Loaded); - Assert.Equal("4.0.0.0", result.Loaded!.Version); - Assert.NotNull(result.AppliedPolicy); - Assert.Equal(PolicyLayer.FrameworkUnification, result.AppliedPolicy!.Source); - Assert.Equal(new Version(3, 0, 0, 0), result.AppliedPolicy.RequestedVersion); - Assert.Equal(new Version(4, 0, 0, 0), result.AppliedPolicy.BoundVersion); + Assert.AreEqual(AssemblyProvenance.Gac, result.Provenance); + Assert.IsNotNull(result.Loaded); + Assert.AreEqual("4.0.0.0", result.Loaded!.Version); + Assert.IsNotNull(result.AppliedPolicy); + Assert.AreEqual(PolicyLayer.FrameworkUnification, result.AppliedPolicy!.Source); + Assert.AreEqual(new Version(3, 0, 0, 0), result.AppliedPolicy.RequestedVersion); + Assert.AreEqual(new Version(4, 0, 0, 0), result.AppliedPolicy.BoundVersion); } /// A weak-named assembly skips the GAC scan entirely. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Bind_NotStrongNamed_SkipsGac() { SkipIfNotWindows(); @@ -449,23 +472,22 @@ public void Bind_NotStrongNamed_SkipsGac() // GAC scan would walk both GAC_MSIL + GAC_; if it fired we'd see at least 2 probes // before the AppLocal fallback. Anything below the AppLocal probe count proves we skipped. // (The AppLocal probe walks 4 paths for a neutral-name miss, so total < 6 means no GAC.) - Assert.True(NetFxBinder.GetProbeCount(ctx) <= 8, - $"Probe count {NetFxBinder.GetProbeCount(ctx)} suggests GAC was scanned for a weak-named assembly"); + Assert.IsLessThanOrEqualTo(8, NetFxBinder.GetProbeCount(ctx), $"Probe count {NetFxBinder.GetProbeCount(ctx)} suggests GAC was scanned for a weak-named assembly"); } - private (NetFxBindingContext Ctx, IReadOnlyDictionary Oracle) LoadFixture() + private static (NetFxBindingContext Ctx, IReadOnlyDictionary Oracle) LoadFixture() { - Assert.NotNull(samples.NetFxBindingRedirectsExe); - Assert.NotNull(samples.NetFxBindingRedirectsOracle); - var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsExe!); + Assert.IsNotNull(Samples.NetFxBindingRedirectsExe); + Assert.IsNotNull(Samples.NetFxBindingRedirectsOracle); + var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsExe!); var ctx = NetFxBindingContext.TryBuild(analyzer); - Assert.NotNull(ctx); - return (ctx!, samples.NetFxBindingRedirectsOracle!); + Assert.IsNotNull(ctx); + return (ctx!, Samples.NetFxBindingRedirectsOracle!); } private static void SkipIfNotWindows() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - Assert.Skip("Test requires Windows (.NET Framework binder)."); + Assert.Inconclusive("Test requires Windows (.NET Framework binder)."); } } diff --git a/tests/Dotsider.Tests/NetFxBindingContextClr2Tests.cs b/tests/Dotsider.Tests/NetFxBindingContextClr2Tests.cs index c09d9b3e..3ddcd6fa 100644 --- a/tests/Dotsider.Tests/NetFxBindingContextClr2Tests.cs +++ b/tests/Dotsider.Tests/NetFxBindingContextClr2Tests.cs @@ -10,111 +10,119 @@ namespace Dotsider.Tests; /// detection signal (mscorlib v2 reference), per-runtime path switching (GAC layout, framework /// runtime dir, machine.config, reference-assemblies tree), and appliesTo filtering. /// -[Collection("SampleAssemblies")] -public sealed class NetFxBindingContextClr2Tests(SampleAssemblyFixture samples) +[TestClass] +public sealed class NetFxBindingContextClr2Tests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// The sample EXE was built with <GenerateTargetFrameworkAttribute>false</>. /// Prove the bug-shape: the issue's premise is that CLR 2 roots in the wild carry no /// TargetFrameworkAttribute. If this assertion fails, the sample fixture has drifted /// and the rest of the CLR 2 test suite would falsely pass via the existing v4 branch. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void BugReproduction_Clr2RootHasNoTargetFrameworkAttribute() { SkipIfNotWindows(); - Assert.NotNull(samples.NetFxBindingRedirectsClr2Exe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsClr2Exe!); - Assert.Null(analyzer.TargetFramework); + Assert.IsNotNull(Samples.NetFxBindingRedirectsClr2Exe); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsClr2Exe!); + Assert.IsNull(analyzer.TargetFramework); } /// /// TryBuild detects the CLR 2 root through the mscorlib v2 reference and returns a Clr2 /// context with the inferred-runtime flag set. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TryBuild_Clr2Root_ReturnsClr2Context() { SkipIfNotWindows(); - Assert.NotNull(samples.NetFxBindingRedirectsClr2Exe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsClr2Exe!); + Assert.IsNotNull(Samples.NetFxBindingRedirectsClr2Exe); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsClr2Exe!); var ctx = NetFxBindingContext.TryBuild(analyzer); - Assert.NotNull(ctx); - Assert.Equal(NetFxRuntimeVersion.Clr2, ctx!.RuntimeVersion); - Assert.True(ctx.IsRuntimeVersionInferred); - Assert.Null(ctx.TargetFramework); // sample has no TFA - Assert.NotEmpty(ctx.Policy.AppConfigRedirects); - Assert.Contains(ctx.PrivatePaths, p => p == "lib"); + Assert.IsNotNull(ctx); + Assert.AreEqual(NetFxRuntimeVersion.Clr2, ctx!.RuntimeVersion); + Assert.IsTrue(ctx.IsRuntimeVersionInferred); + Assert.IsNull(ctx.TargetFramework); // sample has no TFA + Assert.IsNotEmpty(ctx.Policy.AppConfigRedirects); + Assert.Contains(p => p == "lib", ctx.PrivatePaths); } /// /// Regression: v4 detection still wins for net48 roots. Ensures the new Clr2 branch didn't /// reorder the existing TFM gate. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TryBuild_Clr4Root_StillReturnsClr4Context() { SkipIfNotWindows(); - Assert.NotNull(samples.NetFxBindingRedirectsExe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsExe!); + Assert.IsNotNull(Samples.NetFxBindingRedirectsExe); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsExe!); var ctx = NetFxBindingContext.TryBuild(analyzer); - Assert.NotNull(ctx); - Assert.Equal(NetFxRuntimeVersion.Clr4, ctx!.RuntimeVersion); - Assert.False(ctx.IsRuntimeVersionInferred); + Assert.IsNotNull(ctx); + Assert.AreEqual(NetFxRuntimeVersion.Clr4, ctx!.RuntimeVersion); + Assert.IsFalse(ctx.IsRuntimeVersionInferred); } /// /// Clr2 GAC scan list includes all three buckets: GAC_MSIL, the architecture slot, AND the /// bare GAC (CLR 1.x carryover, still consulted by CLR 2 fusion). /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GacScanList_Clr2Root_IncludesAllThreeGacBuckets() { SkipIfNotWindows(); - Assert.NotNull(samples.NetFxBindingRedirectsClr2Exe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsClr2Exe!); + Assert.IsNotNull(Samples.NetFxBindingRedirectsClr2Exe); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsClr2Exe!); var ctx = NetFxBindingContext.TryBuild(analyzer); - Assert.NotNull(ctx); + Assert.IsNotNull(ctx); var list = ctx!.GacScanList(); - Assert.True(list.Count >= 3, $"Expected at least 3 buckets, got {list.Count}"); + Assert.IsGreaterThanOrEqualTo(3, list.Count, $"Expected at least 3 buckets, got {list.Count}"); Assert.EndsWith("GAC_MSIL", list[0], StringComparison.OrdinalIgnoreCase); - Assert.True(list[1].EndsWith("GAC_64", StringComparison.OrdinalIgnoreCase) + Assert.IsTrue(list[1].EndsWith("GAC_64", StringComparison.OrdinalIgnoreCase) || list[1].EndsWith("GAC_32", StringComparison.OrdinalIgnoreCase)); Assert.EndsWith("GAC", list[2], StringComparison.OrdinalIgnoreCase); // All three rooted at the CLR 2 GAC location. var windir = Environment.GetEnvironmentVariable("WINDIR")!; var clr2Root = Path.Combine(windir, "assembly"); - Assert.All(list, p => Assert.StartsWith(clr2Root, p, StringComparison.OrdinalIgnoreCase)); + TestAssert.All(list, p => Assert.StartsWith(clr2Root, p, StringComparison.OrdinalIgnoreCase)); } /// The legacy COM-PIA fallback list is empty for Clr2 — the primary scan list already covers it. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void LegacyGacScanList_Clr2_IsEmpty() { SkipIfNotWindows(); - Assert.NotNull(samples.NetFxBindingRedirectsClr2Exe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsClr2Exe!); + Assert.IsNotNull(Samples.NetFxBindingRedirectsClr2Exe); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsClr2Exe!); var ctx = NetFxBindingContext.TryBuild(analyzer); - Assert.NotNull(ctx); - Assert.Empty(ctx!.LegacyGacScanList()); + Assert.IsNotNull(ctx); + Assert.IsEmpty(ctx!.LegacyGacScanList()); } /// Framework runtime directory for a Clr2 root resolves to v2.0.50727 (architecture-correct). - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FrameworkRuntimeDirectory_Clr2Root_IsV2_0_50727() { SkipIfNotWindows(); - if (!samples.Clr2RuntimePresent) - Assert.Skip("CLR 2 runtime not installed on this host (no v2.0.50727 mscorlib)."); - Assert.NotNull(samples.NetFxBindingRedirectsClr2Exe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsClr2Exe!); + if (!Samples.Clr2RuntimePresent) + Assert.Inconclusive("CLR 2 runtime not installed on this host (no v2.0.50727 mscorlib)."); + Assert.IsNotNull(Samples.NetFxBindingRedirectsClr2Exe); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsClr2Exe!); var ctx = NetFxBindingContext.TryBuild(analyzer); - Assert.NotNull(ctx); + Assert.IsNotNull(ctx); var dir = ctx!.FrameworkRuntimeDirectory(); - Assert.NotNull(dir); + Assert.IsNotNull(dir); Assert.EndsWith("v2.0.50727", dir!, StringComparison.OrdinalIgnoreCase); } @@ -122,11 +130,12 @@ public void FrameworkRuntimeDirectory_Clr2Root_IsV2_0_50727() /// The Clr2 context loads the v2.0.50727 machine.config (not the v4.0.30319 one). Verified /// by ensuring publisher-policy enumeration uses the correct GAC root. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void MachineConfig_Clr2_PathPointsAtV2Config() { SkipIfNotWindows(); - if (!samples.Clr2RuntimePresent) Assert.Skip("CLR 2 runtime not installed."); + if (!Samples.Clr2RuntimePresent) Assert.Inconclusive("CLR 2 runtime not installed."); // BindingPolicy.LoadFrom(... Clr2) consults v2.0.50727\Config\machine.config; verify the // file the binder will read exists. (The exact contents are platform-dependent; we just @@ -134,7 +143,7 @@ public void MachineConfig_Clr2_PathPointsAtV2Config() var windir = Environment.GetEnvironmentVariable("WINDIR")!; var arch = Environment.Is64BitOperatingSystem ? "Framework64" : "Framework"; var machineConfig = Path.Combine(windir, "Microsoft.NET", arch, "v2.0.50727", "Config", "machine.config"); - Assert.True(File.Exists(machineConfig), $"v2.0.50727 machine.config not found at {machineConfig}"); + Assert.IsTrue(File.Exists(machineConfig), $"v2.0.50727 machine.config not found at {machineConfig}"); } /// @@ -142,36 +151,37 @@ public void MachineConfig_Clr2_PathPointsAtV2Config() /// appliesTo filter accepted every Clr2 form (empty, v2, v2.0, v2.0.50727) and /// rejected the v4-only poison block (whose 9.9.9.9 redirect must NOT appear). /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AppliesTo_Clr2_AcceptsAllForms_RejectsV4() { SkipIfNotWindows(); - Assert.NotNull(samples.NetFxBindingRedirectsClr2Exe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsClr2Exe!); + Assert.IsNotNull(Samples.NetFxBindingRedirectsClr2Exe); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsClr2Exe!); var ctx = NetFxBindingContext.TryBuild(analyzer); - Assert.NotNull(ctx); + Assert.IsNotNull(ctx); var redirects = ctx!.Policy.AppConfigRedirects; // Canonical CLR 2 form: SharedDep redirect under appliesTo="v2.0.50727". - Assert.Contains(redirects, r => + Assert.Contains(r => r.Name.Equals("NetFxBindingRedirects.Clr2.SharedDep", StringComparison.OrdinalIgnoreCase) && - r.NewVersion == new Version(2, 0, 0, 0)); + r.NewVersion == new Version(2, 0, 0, 0), redirects); // Short forms appliesTo="v2" and appliesTo="v2.0" should both be honored. - Assert.Contains(redirects, r => r.Name == "Clr2.AppliesToShort.V2"); - Assert.Contains(redirects, r => r.Name == "Clr2.AppliesToShort.V20"); + Assert.Contains(r => r.Name == "Clr2.AppliesToShort.V2", redirects); + Assert.Contains(r => r.Name == "Clr2.AppliesToShort.V20", redirects); // Poison block: v4-only appliesTo points SharedDep at 9.9.9.9. Must NOT have leaked // through to a Clr2 context. - Assert.DoesNotContain(redirects, r => + Assert.DoesNotContain(r => r.Name.Equals("NetFxBindingRedirects.Clr2.SharedDep", StringComparison.OrdinalIgnoreCase) && - r.NewVersion == new Version(9, 9, 9, 9)); + r.NewVersion == new Version(9, 9, 9, 9), redirects); } private static void SkipIfNotWindows() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - Assert.Skip("Test requires Windows (.NET Framework binder)."); + Assert.Inconclusive("Test requires Windows (.NET Framework binder)."); } } diff --git a/tests/Dotsider.Tests/NetFxBindingContextTests.cs b/tests/Dotsider.Tests/NetFxBindingContextTests.cs index eab5d3ae..2eb3b49f 100644 --- a/tests/Dotsider.Tests/NetFxBindingContextTests.cs +++ b/tests/Dotsider.Tests/NetFxBindingContextTests.cs @@ -11,94 +11,102 @@ namespace Dotsider.Tests; /// error split, framework unification), and helpers (, /// ). /// -[Collection("SampleAssemblies")] -public sealed class NetFxBindingContextTests(SampleAssemblyFixture samples) +[TestClass] +public sealed class NetFxBindingContextTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// The fixture-built sample exposes a populated context with all fields. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TryBuild_NetFxRoot_PopulatesAllFields() { SkipIfNotWindows(); - Assert.NotNull(samples.NetFxBindingRedirectsExe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsExe!); + Assert.IsNotNull(Samples.NetFxBindingRedirectsExe); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsExe!); var ctx = NetFxBindingContext.TryBuild(analyzer); - Assert.NotNull(ctx); - Assert.Equal(samples.NetFxBindingRedirectsExe, ctx!.EntryAssemblyPath); - Assert.Equal(Path.GetDirectoryName(samples.NetFxBindingRedirectsExe), ctx.AppBaseDirectory); - Assert.NotNull(ctx.ConfigPath); + Assert.IsNotNull(ctx); + Assert.AreEqual(Samples.NetFxBindingRedirectsExe, ctx!.EntryAssemblyPath); + Assert.AreEqual(Path.GetDirectoryName(Samples.NetFxBindingRedirectsExe), ctx.AppBaseDirectory); + Assert.IsNotNull(ctx.ConfigPath); Assert.StartsWith(".NETFramework,Version=v4", ctx.TargetFramework, StringComparison.OrdinalIgnoreCase); - Assert.NotEmpty(ctx.Policy.AppConfigRedirects); - Assert.Contains(ctx.PrivatePaths, p => p == "lib"); + Assert.IsNotEmpty(ctx.Policy.AppConfigRedirects); + Assert.Contains(p => p == "lib", ctx.PrivatePaths); } /// A non-net48 root produces no binding context. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TryBuild_NetCoreRoot_ReturnsNull() { - using var analyzer = new AssemblyAnalyzer(samples.HelloWorldDll); - Assert.Null(NetFxBindingContext.TryBuild(analyzer)); + using var analyzer = new AssemblyAnalyzer(Samples.HelloWorldDll); + Assert.IsNull(NetFxBindingContext.TryBuild(analyzer)); } /// The sample EXE is AnyCPU IL-only — on a 64-bit host it should bind as Amd64. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void EffectiveArchitecture_AnyCpuIlOnly_OnX64Host_IsAmd64() { SkipIfNotWindows(); if (!Environment.Is64BitOperatingSystem) return; - Assert.NotNull(samples.NetFxBindingRedirectsExe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsExe!); + Assert.IsNotNull(Samples.NetFxBindingRedirectsExe); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsExe!); var ctx = NetFxBindingContext.TryBuild(analyzer); - Assert.NotNull(ctx); - Assert.Equal(NetFxArchitecture.Amd64, ctx!.EffectiveArchitecture); + Assert.IsNotNull(ctx); + Assert.AreEqual(NetFxArchitecture.Amd64, ctx!.EffectiveArchitecture); } /// GAC scan list for an Amd64 root is GAC_MSIL then GAC_64. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GacScanList_Amd64Root_IsMsilThen64() { SkipIfNotWindows(); if (!Environment.Is64BitOperatingSystem) return; - Assert.NotNull(samples.NetFxBindingRedirectsExe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsExe!); + Assert.IsNotNull(Samples.NetFxBindingRedirectsExe); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsExe!); var ctx = NetFxBindingContext.TryBuild(analyzer); - Assert.NotNull(ctx); + Assert.IsNotNull(ctx); var list = ctx!.GacScanList(); - Assert.NotEmpty(list); + Assert.IsNotEmpty(list); Assert.EndsWith("GAC_MSIL", list[0], StringComparison.OrdinalIgnoreCase); Assert.EndsWith("GAC_64", list[1], StringComparison.OrdinalIgnoreCase); } /// Framework runtime directory for an Amd64 root resolves to Framework64\v4.0.30319. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FrameworkRuntimeDir_Amd64Root_IsFramework64() { SkipIfNotWindows(); if (!Environment.Is64BitOperatingSystem) return; - Assert.NotNull(samples.NetFxBindingRedirectsExe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsExe!); + Assert.IsNotNull(Samples.NetFxBindingRedirectsExe); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsExe!); var ctx = NetFxBindingContext.TryBuild(analyzer); - Assert.NotNull(ctx); + Assert.IsNotNull(ctx); var dir = ctx!.FrameworkRuntimeDirectory(); - Assert.NotNull(dir); + Assert.IsNotNull(dir); Assert.Contains("Framework64", dir, StringComparison.OrdinalIgnoreCase); Assert.EndsWith("v4.0.30319", dir, StringComparison.OrdinalIgnoreCase); } /// The sample's app config carries the expected Newtonsoft.Json redirect. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Policy_AppConfig_ParsesAllRedirects() { SkipIfNotWindows(); - Assert.NotNull(samples.NetFxBindingRedirectsExe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsExe!); + Assert.IsNotNull(Samples.NetFxBindingRedirectsExe); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsExe!); var ctx = NetFxBindingContext.TryBuild(analyzer); - Assert.NotNull(ctx); - Assert.Contains(ctx!.Policy.AppConfigRedirects, - r => r.Name == "Newtonsoft.Json" && r.NewVersion == new Version(13, 0, 0, 0)); + Assert.IsNotNull(ctx); + Assert.Contains(r => r.Name == "Newtonsoft.Json" && r.NewVersion == new Version(13, 0, 0, 0), ctx!.Policy.AppConfigRedirects); } /// An assemblyBinding with appliesTo="v2.0" is filtered out for net48 roots. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Policy_AppConfig_HonorsAppliesToFilter() { var dir = Path.Combine(Path.GetTempPath(), "dotsider-policy-applies-to-" + Guid.NewGuid().ToString("N")); @@ -121,13 +129,14 @@ public void Policy_AppConfig_HonorsAppliesToFilter() """); var redirects = BindingPolicy.ParseConfigFile(configPath, PolicyLayer.AppConfig).Redirects; - Assert.Empty(redirects); + Assert.IsEmpty(redirects); } finally { Directory.Delete(dir, recursive: true); } } /// Malformed XML at the document level yields an empty policy (does not throw). - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Policy_AppConfig_MalformedXml_ReturnsEmptyPolicy() { var dir = Path.Combine(Path.GetTempPath(), "dotsider-policy-malformed-" + Guid.NewGuid().ToString("N")); @@ -137,16 +146,17 @@ public void Policy_AppConfig_MalformedXml_ReturnsEmptyPolicy() var configPath = Path.Combine(dir, "fake.exe.config"); File.WriteAllText(configPath, "An invalid bindingRedirect entry is dropped but sibling entries still apply. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Policy_AppConfig_InvalidSectionDroppedButRestApplied() { var dir = Path.Combine(Path.GetTempPath(), "dotsider-policy-invalid-section-" + Guid.NewGuid().ToString("N")); @@ -173,8 +183,8 @@ public void Policy_AppConfig_InvalidSectionDroppedButRestApplied() """); var redirects = BindingPolicy.ParseConfigFile(configPath, PolicyLayer.AppConfig).Redirects; - Assert.Single(redirects); - Assert.Equal("Good", redirects[0].Name); + Assert.ContainsSingle(redirects); + Assert.AreEqual("Good", redirects[0].Name); } finally { Directory.Delete(dir, recursive: true); } } @@ -183,7 +193,8 @@ public void Policy_AppConfig_InvalidSectionDroppedButRestApplied() /// First matching <dependentAssembly> in document order wins per CLR rules /// when two assemblyBinding blocks redirect the same identity to different versions. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Policy_AppConfig_DocumentOrderAcrossMultipleAssemblyBindingBlocks_FirstMatchWins() { var dir = Path.Combine(Path.GetTempPath(), "dotsider-policy-doc-order-" + Guid.NewGuid().ToString("N")); @@ -221,13 +232,14 @@ public void Policy_AppConfig_DocumentOrderAcrossMultipleAssemblyBindingBlocks_Fi PublisherPolicyDisabledFor: []); var requested = new AssemblyRefInfo("Same", "0.5.0.0", "neutral", "0000000000000001"); var (effective, _) = policy.Apply(requested, NetFxArchitecture.Amd64); - Assert.Equal("1.0.0.0", effective.Version); + Assert.AreEqual("1.0.0.0", effective.Version); } finally { Directory.Delete(dir, recursive: true); } } /// processorArchitecture filter excludes non-matching entries. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Policy_AppConfig_ProcessorArchitectureFilter_ExcludesNonMatchingEntries() { var dir = Path.Combine(Path.GetTempPath(), "dotsider-policy-arch-" + Guid.NewGuid().ToString("N")); @@ -254,37 +266,39 @@ public void Policy_AppConfig_ProcessorArchitectureFilter_ExcludesNonMatchingEntr redirects, [], [], [], [], []); var requested = new AssemblyRefInfo("X86Only", "0.5.0.0", "neutral", "0000000000000001"); var (eAmd64, appliedAmd64) = policy.Apply(requested, NetFxArchitecture.Amd64); - Assert.Null(appliedAmd64); - Assert.Equal(requested.Version, eAmd64.Version); + Assert.IsNull(appliedAmd64); + Assert.AreEqual(requested.Version, eAmd64.Version); var (eX86, appliedX86) = policy.Apply(requested, NetFxArchitecture.X86); - Assert.NotNull(appliedX86); - Assert.Equal("1.0.0.0", eX86.Version); + Assert.IsNotNull(appliedX86); + Assert.AreEqual("1.0.0.0", eX86.Version); } finally { Directory.Delete(dir, recursive: true); } } /// Framework unification covers the well-known framework PKTs even with no app config. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Policy_FrameworkUnification_CoversWellKnownFrameworkPkts() { SkipIfNotWindows(); - Assert.NotNull(samples.NetFxBindingRedirectsExe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsExe!); + Assert.IsNotNull(Samples.NetFxBindingRedirectsExe); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsExe!); var ctx = NetFxBindingContext.TryBuild(analyzer); - Assert.NotNull(ctx); + Assert.IsNotNull(ctx); // mscorlib carries PKT b77a5c561934e089 — request 2.0.0.0 should unify to 4.0.0.0. var requested = new AssemblyRefInfo("mscorlib", "2.0.0.0", "neutral", "b77a5c561934e089"); var (effective, applied) = ctx!.Policy.Apply(requested, NetFxArchitecture.Amd64); - Assert.NotNull(applied); - Assert.Equal("4.0.0.0", effective.Version); - Assert.Equal(PolicyLayer.FrameworkUnification, applied!.Source); + Assert.IsNotNull(applied); + Assert.AreEqual("4.0.0.0", effective.Version); + Assert.AreEqual(PolicyLayer.FrameworkUnification, applied!.Source); } /// /// privatePath segments declared inside an <assemblyBinding appliesTo="v2.0…"> block /// must not bleed into a net4 root's probe list. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PrivatePaths_HonorAppliesToFilter_DropNonV4Blocks() { var dir = Path.Combine(Path.GetTempPath(), "dotsider-privatepath-applies-to-" + Guid.NewGuid().ToString("N")); @@ -314,22 +328,24 @@ public void PrivatePaths_HonorAppliesToFilter_DropNonV4Blocks() } /// Privatepath entries are read from app.config. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PrivatePaths_ParsedAndRootedAtAppBase() { SkipIfNotWindows(); - Assert.NotNull(samples.NetFxBindingRedirectsExe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsExe!); + Assert.IsNotNull(Samples.NetFxBindingRedirectsExe); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsExe!); var ctx = NetFxBindingContext.TryBuild(analyzer); - Assert.NotNull(ctx); - Assert.Contains(ctx!.PrivatePaths, p => p == "lib"); + Assert.IsNotNull(ctx); + Assert.Contains(p => p == "lib", ctx!.PrivatePaths); } /// /// Layered policy chains: app config rewrites 1.0 → 2.0, machine.config covers 2.0 → 3.0. /// The binder must apply both, ending at 3.0. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Policy_ChainsLayersSequentially_AppThenMachineCoversIntermediate() { var app = new BindingRedirect( @@ -350,18 +366,19 @@ public void Policy_ChainsLayersSequentially_AppThenMachineCoversIntermediate() var requested = new AssemblyRefInfo("Chain", "1.0.0.0", "neutral", "0000000000000001"); var (effective, applied) = policy.Apply(requested, NetFxArchitecture.Amd64); - Assert.Equal("3.0.0.0", effective.Version); - Assert.NotNull(applied); - Assert.Equal(PolicyLayer.MachineConfig, applied!.Source); - Assert.Equal(new Version(1, 0, 0, 0), applied.RequestedVersion); - Assert.Equal(new Version(3, 0, 0, 0), applied.BoundVersion); + Assert.AreEqual("3.0.0.0", effective.Version); + Assert.IsNotNull(applied); + Assert.AreEqual(PolicyLayer.MachineConfig, applied!.Source); + Assert.AreEqual(new Version(1, 0, 0, 0), applied.RequestedVersion); + Assert.AreEqual(new Version(3, 0, 0, 0), applied.BoundVersion); } /// /// Runtime-scoped <publisherPolicy apply="no"/> suppresses publisher policy for every /// bind in the AppDomain — including identities that have no <dependentAssembly>. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Policy_RuntimeScopedPublisherPolicyApplyNo_SuppressesGloballyForAllIdentities() { var dir = Path.Combine(Path.GetTempPath(), "dotsider-policy-runtime-disable-" + Guid.NewGuid().ToString("N")); @@ -385,7 +402,7 @@ public void Policy_RuntimeScopedPublisherPolicyApplyNo_SuppressesGloballyForAllI """); var parsed = BindingPolicy.ParseConfigFile(configPath, PolicyLayer.AppConfig); - Assert.True(parsed.PublisherPolicyDisabledGlobally); + Assert.IsTrue(parsed.PublisherPolicyDisabledGlobally); var pub = new BindingRedirect( PolicyLayer.PublisherPolicy, @@ -402,33 +419,32 @@ public void Policy_RuntimeScopedPublisherPolicyApplyNo_SuppressesGloballyForAllI var requested = new AssemblyRefInfo("Untouched", "1.0.0.0", "neutral", "0000000000000077"); var (effective, applied) = policy.Apply(requested, NetFxArchitecture.Amd64); - Assert.Equal(requested.Version, effective.Version); - Assert.Null(applied); + Assert.AreEqual(requested.Version, effective.Version); + Assert.IsNull(applied); } finally { Directory.Delete(dir, recursive: true); } } /// CodeBase entries are read from app.config. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void CodeBases_ParsedFromConfig() { SkipIfNotWindows(); - Assert.NotNull(samples.NetFxBindingRedirectsExe); - using var analyzer = new AssemblyAnalyzer(samples.NetFxBindingRedirectsExe!); + Assert.IsNotNull(Samples.NetFxBindingRedirectsExe); + using var analyzer = new AssemblyAnalyzer(Samples.NetFxBindingRedirectsExe!); var ctx = NetFxBindingContext.TryBuild(analyzer); - Assert.NotNull(ctx); - Assert.Contains(ctx!.Policy.CodeBases, - c => c.Name == "NetFxBindingRedirects.CodeBaseLib" + Assert.IsNotNull(ctx); + Assert.Contains(c => c.Name == "NetFxBindingRedirects.CodeBaseLib" && c.Version == new Version(2, 0, 0, 0) - && c.Href.EndsWith("NetFxBindingRedirects.CodeBaseLib.dll", StringComparison.OrdinalIgnoreCase)); - Assert.Contains(ctx.Policy.CodeBases, - c => c.Name == "NetFxBindingRedirects.MissingCodeBase" - && c.Href.EndsWith("Missing.dll", StringComparison.OrdinalIgnoreCase)); + && c.Href.EndsWith("NetFxBindingRedirects.CodeBaseLib.dll", StringComparison.OrdinalIgnoreCase), ctx!.Policy.CodeBases); + Assert.Contains(c => c.Name == "NetFxBindingRedirects.MissingCodeBase" + && c.Href.EndsWith("Missing.dll", StringComparison.OrdinalIgnoreCase), ctx.Policy.CodeBases); } private static void SkipIfNotWindows() { if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - Assert.Skip("Test requires Windows (.NET Framework binder)."); + Assert.Inconclusive("Test requires Windows (.NET Framework binder)."); } } diff --git a/tests/Dotsider.Tests/NuGetModeViewTests.cs b/tests/Dotsider.Tests/NuGetModeViewTests.cs index 2dfa4aa2..b14c1eb4 100644 --- a/tests/Dotsider.Tests/NuGetModeViewTests.cs +++ b/tests/Dotsider.Tests/NuGetModeViewTests.cs @@ -8,9 +8,11 @@ namespace Dotsider.Tests; /// /// Tests for Nu Get Mode View. /// -[Collection("SampleAssemblies")] -public class NuGetModeViewTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class NuGetModeViewTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _hex1bApp; @@ -28,7 +30,7 @@ public class NuGetModeViewTests(SampleAssemblyFixture samples) : IDisposable _hex1bApp = new Hex1bApp( ctx => { - _state ??= new NuGetState(_hex1bApp!, samples.RichLibraryNupkg); + _state ??= new NuGetState(_hex1bApp!, Samples.RichLibraryNupkg); nugetApp ??= new NuGetApp(_state); return Task.FromResult(nugetApp.Build(ctx)); }, @@ -43,11 +45,12 @@ public class NuGetModeViewTests(SampleAssemblyFixture samples) : IDisposable /// /// Verifies nu get app launches shows package info. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task NuGetApp_Launches_ShowsPackageInfo() { var (terminal, app) = CreateNuGetApp(); - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); @@ -64,11 +67,12 @@ public async Task NuGetApp_Launches_ShowsPackageInfo() /// /// Verifies nu get app shows file list. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task NuGetApp_ShowsFileList() { var (terminal, app) = CreateNuGetApp(); - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); @@ -87,11 +91,12 @@ public async Task NuGetApp_ShowsFileList() /// /// Verifies quit key exits nu get app. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task QuitKey_ExitsNuGetApp() { var (terminal, app) = CreateNuGetApp(); - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); @@ -103,17 +108,18 @@ public async Task QuitKey_ExitsNuGetApp() .ApplyAsync(terminal, ct); var completed = await Task.WhenAny(runTask, Task.Delay(5000, ct)); - Assert.Equal(runTask, completed); + Assert.AreEqual(runTask, completed); } /// /// Verifies enter on dll row opens dll inspector. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Enter_OnDllRow_OpensDllInspector() { var (terminal, app) = CreateNuGetApp(); - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); @@ -128,9 +134,9 @@ public async Task Enter_OnDllRow_OpensDllInspector() .Build() .ApplyAsync(terminal, ct); - Assert.False(_state!.IsBrowsingPackage); - Assert.NotNull(_state.SelectedDllState); - Assert.NotNull(_state.SelectedDllEntry); + Assert.IsFalse(_state!.IsBrowsingPackage); + Assert.IsNotNull(_state.SelectedDllState); + Assert.IsNotNull(_state.SelectedDllEntry); await runTask.ContinueWith(_ => { }, ct); } @@ -138,11 +144,12 @@ public async Task Enter_OnDllRow_OpensDllInspector() /// /// Verifies search activates and dismisses. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Search_ActivatesAndDismisses() { var (terminal, app) = CreateNuGetApp(); - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); @@ -159,7 +166,7 @@ public async Task Search_ActivatesAndDismisses() .Build() .ApplyAsync(terminal, ct); - Assert.False(_state!.BrowserSearch.IsActive); + Assert.IsFalse(_state!.BrowserSearch.IsActive); await runTask.ContinueWith(_ => { }, ct); } @@ -167,11 +174,12 @@ public async Task Search_ActivatesAndDismisses() /// /// Verifies dll inspector depth limit shows error in hints bar. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task DllInspector_DepthLimit_ShowsErrorInHintsBar() { var (terminal, app) = CreateNuGetApp(); - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); var depthLimitHit = false; @@ -191,11 +199,11 @@ public async Task DllInspector_DepthLimit_ShowsErrorInHintsBar() var dllState = _state!.SelectedDllState!; for (var i = 0; i < DotsiderState.MaxNavigationDepth; i++) { - var path = i % 2 == 0 ? samples.RichLibraryDll : samples.EmptyLibDll; + var path = i % 2 == 0 ? Samples.RichLibraryDll : Samples.EmptyLibDll; dllState.PushAssembly(path); } // This push should fail with depth limit - dllState.PushAssembly(samples.ComplexAppDll); + dllState.PushAssembly(Samples.ComplexAppDll); _state.App.Invalidate(); depthLimitHit = true; } @@ -206,7 +214,7 @@ public async Task DllInspector_DepthLimit_ShowsErrorInHintsBar() .Build() .ApplyAsync(terminal, ct); - Assert.Contains("depth limit", _state!.SelectedDllState!.NavigationError); + Assert.Contains("depth limit", _state!.SelectedDllState!.NavigationError!); await runTask.ContinueWith(_ => { }, ct); } @@ -217,13 +225,15 @@ public async Task DllInspector_DepthLimit_ShowsErrorInHintsBar() /// the behavior already covered for standalone dotsider in /// . /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task NuGet_EscBack_FromPeIlHexChain_TwoEscsReturnToPe() { var (terminal, app) = CreateNuGetApp(); - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var runTask = app.RunAsync(ct); await Task.Delay(100, ct); + var chainPrepared = false; await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.InAlternateScreen, TimeSpan.FromSeconds(10)) @@ -236,14 +246,19 @@ public async Task NuGet_EscBack_FromPeIlHexChain_TwoEscsReturnToPe() // Set up a PE → IL → Hex chain on the inner DLL state, then drive // real Esc keys through the NuGet Escape handler. var dllState = _state!.SelectedDllState!; - dllState.CurrentTab = TabId.PeMetadata; - dllState.PeSubTab = PeSubTabId.MethodDef; - var method = dllState.Analyzer.MethodDefs.First(m => m.Rva > 0); - dllState.NavigateToIlMethod(method); - dllState.NavigateToHexOffset(method.Rva); - _state.App.Invalidate(); - return dllState.CurrentTab == TabId.HexDump; + if (!chainPrepared) + { + dllState.CurrentTab = TabId.PeMetadata; + dllState.PeSubTab = PeSubTabId.MethodDef; + var method = dllState.Analyzer.MethodDefs.First(m => m.Rva > 0); + dllState.NavigateToIlMethod(method); + dllState.NavigateToHexOffset(method.Rva); + chainPrepared = true; + } + + return dllState.CurrentTab == TabId.HexDump && dllState.CrossViewBackTarget is not null; }, TimeSpan.FromSeconds(10)) + .WaitUntil(s => s.ContainsText("Data Interpretation"), TimeSpan.FromSeconds(10)) .Key(Hex1bKey.Escape) .WaitUntil(_ => _state!.SelectedDllState!.CurrentTab == TabId.IlInspector, TimeSpan.FromSeconds(10)) @@ -257,11 +272,11 @@ public async Task NuGet_EscBack_FromPeIlHexChain_TwoEscsReturnToPe() // Two Escs landed back on PE Metadata with the original sub-tab — and // the user is still inside the DLL inspector (not ejected to the package // browser, which is the pre-fix fall-through branch in NuGet's Esc handler). - Assert.NotNull(_state!.SelectedDllState); - Assert.False(_state.IsBrowsingPackage); - Assert.Equal(TabId.PeMetadata, _state.SelectedDllState.CurrentTab); - Assert.Equal(PeSubTabId.MethodDef, _state.SelectedDllState.PeSubTab); - Assert.Null(_state.SelectedDllState.CrossViewBackTarget); + Assert.IsNotNull(_state!.SelectedDllState); + Assert.IsFalse(_state.IsBrowsingPackage); + Assert.AreEqual(TabId.PeMetadata, _state.SelectedDllState.CurrentTab); + Assert.AreEqual(PeSubTabId.MethodDef, _state.SelectedDllState.PeSubTab); + Assert.IsNull(_state.SelectedDllState.CrossViewBackTarget); await runTask.ContinueWith(_ => { }, ct); } @@ -271,10 +286,10 @@ public async Task NuGet_EscBack_FromPeIlHexChain_TwoEscsReturnToPe() /// public void Dispose() { + GC.SuppressFinalize(this); _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/NuGetModeYankIntegrationTests.cs b/tests/Dotsider.Tests/NuGetModeYankIntegrationTests.cs index 7f0b71a4..1a348179 100644 --- a/tests/Dotsider.Tests/NuGetModeYankIntegrationTests.cs +++ b/tests/Dotsider.Tests/NuGetModeYankIntegrationTests.cs @@ -8,9 +8,11 @@ namespace Dotsider.Tests; /// /// Tests for Nu Get Mode Yank Integration. /// -[Collection("SampleAssemblies")] -public class NuGetModeYankIntegrationTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class NuGetModeYankIntegrationTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private ClipboardCapturingWorkloadAdapter? _clipboardAdapter; private Hex1bTerminal? _terminal; @@ -20,7 +22,7 @@ public class NuGetModeYankIntegrationTests(SampleAssemblyFixture samples) : IDis private (Hex1bTerminal terminal, Hex1bApp app, CancellationToken ct) Launch() { - _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + _cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); _workload = new Hex1bAppWorkloadAdapter(); _clipboardAdapter = new ClipboardCapturingWorkloadAdapter(_workload); _terminal = Hex1bTerminal.CreateBuilder() @@ -33,7 +35,7 @@ public class NuGetModeYankIntegrationTests(SampleAssemblyFixture samples) : IDis _hex1bApp = new Hex1bApp( ctx => { - _state ??= new NuGetState(_hex1bApp!, samples.RichLibraryNupkg); + _state ??= new NuGetState(_hex1bApp!, Samples.RichLibraryNupkg); nugetApp ??= new NuGetApp(_state); return Task.FromResult(nugetApp.Build(ctx)); }, @@ -51,7 +53,8 @@ public class NuGetModeYankIntegrationTests(SampleAssemblyFixture samples) : IDis /// /// Verifies browser initial focus on first dll row. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Browser_InitialFocusOnFirstDllRow() { var (terminal, app, ct) = Launch(); @@ -63,8 +66,8 @@ public async Task Browser_InitialFocusOnFirstDllRow() .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(_state!.FileTreeFocusedKey); - Assert.False(_state.App.FocusedNode is EditorNode, + Assert.IsNotNull(_state!.FileTreeFocusedKey); + Assert.IsFalse(_state.App.FocusedNode is EditorNode, "Initial focus should be on table, not editor"); _cts!.Cancel(); @@ -74,7 +77,8 @@ public async Task Browser_InitialFocusOnFirstDllRow() /// /// Verifies browser tab toggles focus. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Browser_TabTogglesFocus() { var (terminal, app, ct) = Launch(); @@ -115,7 +119,8 @@ public async Task Browser_TabTogglesFocus() /// /// Verifies browser yank on dll row shows notification and flash. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Browser_YankOnDllRow_ShowsNotificationAndFlash() { var (terminal, app, ct) = Launch(); @@ -128,9 +133,9 @@ public async Task Browser_YankOnDllRow_ShowsNotificationAndFlash() .ApplyAsync(terminal, ct); // Yank the focused DLL row — payload should be the file path - Assert.NotNull(_state!.FileTreeFocusedKey); + Assert.IsNotNull(_state!.FileTreeFocusedKey); var expectedPath = _state.FileTreeFocusedKey as string; - Assert.NotNull(expectedPath); + Assert.IsNotNull(expectedPath); await new Hex1bTerminalInputSequenceBuilder() .Type("y") @@ -138,13 +143,13 @@ public async Task Browser_YankOnDllRow_ShowsNotificationAndFlash() .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(_state.YankNotification); + Assert.IsNotNull(_state.YankNotification); Assert.Contains("lib/", _state.YankNotification); // DLL path contains lib/ directory // Verify the actual OSC 52 clipboard payload emitted by ctx.CopyToClipboard - Assert.True(_clipboardAdapter!.ClipboardWrites.TryDequeue(out var actualClipboard), + Assert.IsTrue(_clipboardAdapter!.ClipboardWrites.TryDequeue(out var actualClipboard), "CopyToClipboard should have emitted an OSC 52 sequence"); - Assert.Equal(expectedPath, actualClipboard); + Assert.AreEqual(expectedPath, actualClipboard); // Flash should have fired and cleared await new Hex1bTerminalInputSequenceBuilder() @@ -167,7 +172,8 @@ public async Task Browser_YankOnDllRow_ShowsNotificationAndFlash() /// /// Verifies drill into saves focused key esc restores. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task DrillInto_SavesFocusedKey_EscRestores() { var (terminal, app, ct) = Launch(); @@ -180,7 +186,7 @@ public async Task DrillInto_SavesFocusedKey_EscRestores() .ApplyAsync(terminal, ct); var savedKey = _state!.FileTreeFocusedKey; - Assert.NotNull(savedKey); + Assert.IsNotNull(savedKey); // Enter → drill into DLL await new Hex1bTerminalInputSequenceBuilder() @@ -190,7 +196,7 @@ public async Task DrillInto_SavesFocusedKey_EscRestores() .Build() .ApplyAsync(terminal, ct); - Assert.Equal(savedKey, _state.SavedFileTreeFocusedKey); + Assert.AreEqual(savedKey, _state.SavedFileTreeFocusedKey); // Esc → back to package await new Hex1bTerminalInputSequenceBuilder() @@ -199,7 +205,7 @@ public async Task DrillInto_SavesFocusedKey_EscRestores() .Build() .ApplyAsync(terminal, ct); - Assert.Equal(savedKey, _state.FileTreeFocusedKey); + Assert.AreEqual(savedKey, _state.FileTreeFocusedKey); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -210,7 +216,8 @@ public async Task DrillInto_SavesFocusedKey_EscRestores() /// /// Verifies child search digits do not switch tabs. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ChildSearch_DigitsDoNotSwitchTabs() { var (terminal, app, ct) = Launch(); @@ -243,7 +250,7 @@ public async Task ChildSearch_DigitsDoNotSwitchTabs() .ApplyAsync(terminal, ct); await Task.Delay(100, ct); - Assert.Equal(tabBefore, dllState.CurrentTab); + Assert.AreEqual(tabBefore, dllState.CurrentTab); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -254,7 +261,8 @@ public async Task ChildSearch_DigitsDoNotSwitchTabs() /// /// Verifies hex dump esc from normal mode returns to package. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task HexDump_EscFromNormalMode_ReturnsToPackage() { var (terminal, app, ct) = Launch(); @@ -289,7 +297,7 @@ public async Task HexDump_EscFromNormalMode_ReturnsToPackage() .Build() .ApplyAsync(terminal, ct); - Assert.False(dllState.HexEditorState.IsReadOnly); + Assert.IsFalse(dllState.HexEditorState.IsReadOnly); // Esc 1: exit insert mode (NOT back to package) await new Hex1bTerminalInputSequenceBuilder() @@ -298,8 +306,8 @@ public async Task HexDump_EscFromNormalMode_ReturnsToPackage() .Build() .ApplyAsync(terminal, ct); - Assert.True(dllState.HexEditorState.IsReadOnly); - Assert.False(_state.IsBrowsingPackage, "Should still be in DLL inspector"); + Assert.IsTrue(dllState.HexEditorState.IsReadOnly); + Assert.IsFalse(_state.IsBrowsingPackage, "Should still be in DLL inspector"); // Esc 2: back to package await new Hex1bTerminalInputSequenceBuilder() @@ -317,7 +325,8 @@ public async Task HexDump_EscFromNormalMode_ReturnsToPackage() /// /// Verifies yank timer race leave dll before flash clears no exception. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task YankTimerRace_LeaveDllBeforeFlashClears_NoException() { var (terminal, app, ct) = Launch(); @@ -349,7 +358,7 @@ public async Task YankTimerRace_LeaveDllBeforeFlashClears_NoException() await Task.Delay(300, ct); // App should still be running fine - Assert.True(_state!.IsBrowsingPackage); + Assert.IsTrue(_state!.IsBrowsingPackage); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -360,7 +369,8 @@ public async Task YankTimerRace_LeaveDllBeforeFlashClears_NoException() /// /// Verifies package info selection yank works. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PackageInfo_SelectionYank_Works() { var (terminal, app, ct) = Launch(); @@ -394,7 +404,7 @@ public async Task PackageInfo_SelectionYank_Works() .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(_state!.YankNotification); + Assert.IsNotNull(_state!.YankNotification); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -405,7 +415,8 @@ public async Task PackageInfo_SelectionYank_Works() /// /// Verifies package info double click word selection yank works. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PackageInfo_DoubleClickWordSelectionYank_Works() { var (terminal, app, ct) = Launch(); @@ -430,14 +441,23 @@ public async Task PackageInfo_DoubleClickWordSelectionYank_Works() .Build() .ApplyAsync(terminal, ct); - Assert.True(matches.Count > 0); + Assert.IsGreaterThan(0, matches.Count); var (row, col) = matches[0]; - // Click to focus editor, then double-click to select word. - // Each Automator step completes through the input pipeline before the next. + // Focus the editor without consuming a mouse click, then queue two real + // clicks so Hex1bApp's click-count state machine observes a double-click. var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(5)); - await auto.ClickAtAsync(col, row, ct: ct); - await auto.DoubleClickAtAsync(col, row, ct: ct); + _state!.App.RequestFocus(node => + node is EditorNode { State: var es } && es == _state.PackageInfoEditorState); + _state.App.Invalidate(); + await auto.WaitUntilAsync(_ => + _state.App.FocusedNode is EditorNode { State: var es } && es == _state.PackageInfoEditorState, + description: "package info editor focused"); + await new Hex1bTerminalInputSequenceBuilder() + .ClickAt(col, row) + .ClickAt(col, row) + .Build() + .ApplyAsync(terminal, ct); // Wait for selection via screen state (not internal state polling) await auto.WaitUntilAsync( @@ -451,7 +471,7 @@ await auto.WaitUntilAsync( .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(_state!.YankNotification); + Assert.IsNotNull(_state!.YankNotification); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -462,7 +482,8 @@ await auto.WaitUntilAsync( /// /// Verifies dll inspector editor yank works. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task DllInspector_EditorYank_Works() { var (terminal, app, ct) = Launch(); @@ -503,7 +524,8 @@ public async Task DllInspector_EditorYank_Works() /// /// Verifies hex jump dialog digits go into input esc closes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task HexJumpDialog_DigitsGoIntoInput_EscCloses() { var (terminal, app, ct) = Launch(); @@ -536,8 +558,8 @@ public async Task HexJumpDialog_DigitsGoIntoInput_EscCloses() .Build() .ApplyAsync(terminal, ct); - Assert.True(dllState.HexJumpDialogOpen); - Assert.False(_state.IsBrowsingPackage, "Should still be in DLL inspector"); + Assert.IsTrue(dllState.HexJumpDialogOpen); + Assert.IsFalse(_state.IsBrowsingPackage, "Should still be in DLL inspector"); // Esc closes dialog await new Hex1bTerminalInputSequenceBuilder() @@ -546,7 +568,7 @@ public async Task HexJumpDialog_DigitsGoIntoInput_EscCloses() .Build() .ApplyAsync(terminal, ct); - Assert.False(_state.IsBrowsingPackage, "Should still be in DLL inspector after closing dialog"); + Assert.IsFalse(_state.IsBrowsingPackage, "Should still be in DLL inspector after closing dialog"); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -557,7 +579,8 @@ public async Task HexJumpDialog_DigitsGoIntoInput_EscCloses() /// /// Verifies child search q does not quit. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ChildSearch_QDoesNotQuit() { var (terminal, app, ct) = Launch(); @@ -601,7 +624,8 @@ public async Task ChildSearch_QDoesNotQuit() /// /// Verifies hex esc chain insert then search then back. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task HexEscChain_InsertThenSearchThenBack() { var (terminal, app, ct) = Launch(); @@ -634,7 +658,7 @@ public async Task HexEscChain_InsertThenSearchThenBack() .Build() .ApplyAsync(terminal, ct); - Assert.False(dllState.HexEditorState.IsReadOnly); + Assert.IsFalse(dllState.HexEditorState.IsReadOnly); // Esc 1: exit insert mode await new Hex1bTerminalInputSequenceBuilder() @@ -643,8 +667,8 @@ public async Task HexEscChain_InsertThenSearchThenBack() .Build() .ApplyAsync(terminal, ct); - Assert.True(dllState.HexEditorState.IsReadOnly); - Assert.False(_state.IsBrowsingPackage); + Assert.IsTrue(dllState.HexEditorState.IsReadOnly); + Assert.IsFalse(_state.IsBrowsingPackage); // Start a search await new Hex1bTerminalInputSequenceBuilder() @@ -663,8 +687,8 @@ public async Task HexEscChain_InsertThenSearchThenBack() .Build() .ApplyAsync(terminal, ct); - Assert.Empty(dllState.HexMatchOffsets); - Assert.False(_state.IsBrowsingPackage); + Assert.IsEmpty(dllState.HexMatchOffsets); + Assert.IsFalse(_state.IsBrowsingPackage); // Esc 3: back to package await new Hex1bTerminalInputSequenceBuilder() @@ -682,7 +706,8 @@ public async Task HexEscChain_InsertThenSearchThenBack() /// /// Verifies child search y does not yank. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ChildSearch_YDoesNotYank() { var (terminal, app, ct) = Launch(); @@ -717,7 +742,7 @@ public async Task ChildSearch_YDoesNotYank() .ApplyAsync(terminal, ct); Assert.Contains("y", dllState.Search[dllState.CurrentTab].Query ?? ""); - Assert.Null(_state.YankNotification); + Assert.IsNull(_state.YankNotification); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -728,7 +753,8 @@ public async Task ChildSearch_YDoesNotYank() /// /// Verifies dll inspector row yank flash sets and clears. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task DllInspector_RowYank_FlashSetsAndClears() { var (terminal, app, ct) = Launch(); @@ -764,7 +790,8 @@ public async Task DllInspector_RowYank_FlashSetsAndClears() /// /// Verifies package info yy yanks current line. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PackageInfo_YY_YanksCurrentLine() { var (terminal, app, ct) = Launch(); @@ -791,10 +818,10 @@ public async Task PackageInfo_YY_YanksCurrentLine() .Build() .ApplyAsync(terminal, ct); - Assert.True(_clipboardAdapter!.ClipboardWrites.TryDequeue(out var yankedText), + Assert.IsTrue(_clipboardAdapter!.ClipboardWrites.TryDequeue(out var yankedText), "CopyToClipboard should have emitted an OSC 52 sequence"); - Assert.True(yankedText.Length > 0); + Assert.IsGreaterThan(0, yankedText.Length); Assert.DoesNotContain("\n", yankedText); _cts!.Cancel(); @@ -806,12 +833,12 @@ public async Task PackageInfo_YY_YanksCurrentLine() /// public void Dispose() { + GC.SuppressFinalize(this); _cts?.Cancel(); _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); _cts?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/NuGetPackageAnalyzerTests.cs b/tests/Dotsider.Tests/NuGetPackageAnalyzerTests.cs index 8e078258..cfaf8498 100644 --- a/tests/Dotsider.Tests/NuGetPackageAnalyzerTests.cs +++ b/tests/Dotsider.Tests/NuGetPackageAnalyzerTests.cs @@ -5,106 +5,117 @@ namespace Dotsider.Tests; /// /// Tests for Nu Get Package Analyzer. /// -[Collection("SampleAssemblies")] -public class NuGetPackageAnalyzerTests(SampleAssemblyFixture samples) +[TestClass] +public class NuGetPackageAnalyzerTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// Verifies rich library nupkg has correct package id. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibraryNupkg_HasCorrectPackageId() { - using var pkg = new NuGetPackageAnalyzer(samples.RichLibraryNupkg); - Assert.Equal("RichLibrary", pkg.PackageId); + using var pkg = new NuGetPackageAnalyzer(Samples.RichLibraryNupkg); + Assert.AreEqual("RichLibrary", pkg.PackageId); } /// /// Verifies rich library nupkg has correct version. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibraryNupkg_HasCorrectVersion() { - using var pkg = new NuGetPackageAnalyzer(samples.RichLibraryNupkg); - Assert.Equal("2.5.1", pkg.PackageVersion); + using var pkg = new NuGetPackageAnalyzer(Samples.RichLibraryNupkg); + Assert.AreEqual("2.5.1", pkg.PackageVersion); } /// /// Verifies rich library nupkg has files. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibraryNupkg_HasFiles() { - using var pkg = new NuGetPackageAnalyzer(samples.RichLibraryNupkg); - Assert.NotEmpty(pkg.Files); + using var pkg = new NuGetPackageAnalyzer(Samples.RichLibraryNupkg); + Assert.IsNotEmpty(pkg.Files); } /// /// Verifies rich library nupkg has dll files. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibraryNupkg_HasDllFiles() { - using var pkg = new NuGetPackageAnalyzer(samples.RichLibraryNupkg); - Assert.NotEmpty(pkg.DllFiles); - Assert.All(pkg.DllFiles, f => Assert.True(f.IsDll)); + using var pkg = new NuGetPackageAnalyzer(Samples.RichLibraryNupkg); + Assert.IsNotEmpty(pkg.DllFiles); + TestAssert.All(pkg.DllFiles, f => Assert.IsTrue(f.IsDll)); } /// /// Verifies rich library nupkg has nuspec file. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibraryNupkg_HasNuspecFile() { - using var pkg = new NuGetPackageAnalyzer(samples.RichLibraryNupkg); - Assert.Contains(pkg.Files, f => f.Name.EndsWith(".nuspec")); + using var pkg = new NuGetPackageAnalyzer(Samples.RichLibraryNupkg); + Assert.Contains(f => f.Name.EndsWith(".nuspec"), pkg.Files); } /// /// Verifies open dll returns working analyzer. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void OpenDll_ReturnsWorkingAnalyzer() { - using var pkg = new NuGetPackageAnalyzer(samples.RichLibraryNupkg); + using var pkg = new NuGetPackageAnalyzer(Samples.RichLibraryNupkg); var dll = pkg.DllFiles[0]; using var analyzer = pkg.OpenDll(dll); - Assert.NotNull(analyzer); - Assert.True(analyzer.HasMetadata); - Assert.Equal("RichLibrary", analyzer.AssemblyName); + Assert.IsNotNull(analyzer); + Assert.IsTrue(analyzer.HasMetadata); + Assert.AreEqual("RichLibrary", analyzer.AssemblyName); } /// /// Verifies open dll matches standalone assembly. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void OpenDll_MatchesStandaloneAssembly() { - using var pkg = new NuGetPackageAnalyzer(samples.RichLibraryNupkg); + using var pkg = new NuGetPackageAnalyzer(Samples.RichLibraryNupkg); var dll = pkg.DllFiles[0]; using var fromPkg = pkg.OpenDll(dll); - using var standalone = new AssemblyAnalyzer(samples.RichLibraryDll); + using var standalone = new AssemblyAnalyzer(Samples.RichLibraryDll); // Both should have the same types - Assert.Equal(standalone.TypeDefs.Count, fromPkg.TypeDefs.Count); + Assert.HasCount(standalone.TypeDefs.Count, fromPkg.TypeDefs); } /// /// Verifies has authors and description. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HasAuthorsAndDescription() { - using var pkg = new NuGetPackageAnalyzer(samples.RichLibraryNupkg); - Assert.NotNull(pkg.Authors); - Assert.NotNull(pkg.Description); + using var pkg = new NuGetPackageAnalyzer(Samples.RichLibraryNupkg); + Assert.IsNotNull(pkg.Authors); + Assert.IsNotNull(pkg.Description); } /// /// Verifies Dispose can be called multiple times without side effects. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Dispose_IsIdempotent() { - var pkg = new NuGetPackageAnalyzer(samples.RichLibraryNupkg); + var pkg = new NuGetPackageAnalyzer(Samples.RichLibraryNupkg); pkg.Dispose(); pkg.Dispose(); // should not throw } @@ -112,9 +123,10 @@ public void Dispose_IsIdempotent() /// /// Verifies invalid path throws. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void InvalidPath_Throws() { - Assert.ThrowsAny(() => new NuGetPackageAnalyzer("/nonexistent/package.nupkg")); + Assert.Throws(() => new NuGetPackageAnalyzer("/nonexistent/package.nupkg")); } } diff --git a/tests/Dotsider.Tests/NuGetStateTests.cs b/tests/Dotsider.Tests/NuGetStateTests.cs index e8ed5821..f3e59ec6 100644 --- a/tests/Dotsider.Tests/NuGetStateTests.cs +++ b/tests/Dotsider.Tests/NuGetStateTests.cs @@ -6,9 +6,11 @@ namespace Dotsider.Tests; /// /// Tests for Nu Get State. /// -[Collection("SampleAssemblies")] -public class NuGetStateTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class NuGetStateTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _app; @@ -30,63 +32,68 @@ private Hex1bApp CreateApp() /// /// Verifies construct package metadata populated. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Construct_PackageMetadataPopulated() { var app = CreateApp(); - using var state = new NuGetState(app, samples.RichLibraryNupkg); - Assert.Equal("RichLibrary", state.Package.PackageId); - Assert.Equal("2.5.1", state.Package.PackageVersion); + using var state = new NuGetState(app, Samples.RichLibraryNupkg); + Assert.AreEqual("RichLibrary", state.Package.PackageId); + Assert.AreEqual("2.5.1", state.Package.PackageVersion); } /// /// Verifies construct file list populated. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Construct_FileListPopulated() { var app = CreateApp(); - using var state = new NuGetState(app, samples.RichLibraryNupkg); - Assert.NotEmpty(state.Package.Files); - Assert.NotEmpty(state.Package.DllFiles); + using var state = new NuGetState(app, Samples.RichLibraryNupkg); + Assert.IsNotEmpty(state.Package.Files); + Assert.IsNotEmpty(state.Package.DllFiles); } /// /// Verifies is browsing package default true. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void IsBrowsingPackage_DefaultTrue() { var app = CreateApp(); - using var state = new NuGetState(app, samples.RichLibraryNupkg); - Assert.True(state.IsBrowsingPackage); + using var state = new NuGetState(app, Samples.RichLibraryNupkg); + Assert.IsTrue(state.IsBrowsingPackage); } /// /// Verifies drill into dll creates inspector state. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DrillInto_DllCreatesInspectorState() { var app = CreateApp(); - using var state = new NuGetState(app, samples.RichLibraryNupkg); + using var state = new NuGetState(app, Samples.RichLibraryNupkg); var dll = state.Package.DllFiles[0]; var analyzer = state.Package.OpenDll(dll); state.SelectedDllState = new DotsiderState(app, analyzer); state.SelectedDllEntry = dll; state.IsBrowsingPackage = false; - Assert.False(state.IsBrowsingPackage); - Assert.NotNull(state.SelectedDllState); + Assert.IsFalse(state.IsBrowsingPackage); + Assert.IsNotNull(state.SelectedDllState); } /// /// Verifies dispose cleans up. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Dispose_CleansUp() { var app = CreateApp(); - var state = new NuGetState(app, samples.RichLibraryNupkg); + var state = new NuGetState(app, Samples.RichLibraryNupkg); state.Dispose(); state.Dispose(); // idempotent } @@ -96,9 +103,9 @@ public void Dispose_CleansUp() /// public void Dispose() { + GC.SuppressFinalize(this); _app?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/PdataReaderTests.cs b/tests/Dotsider.Tests/PdataReaderTests.cs index 0dfb11e3..3ef0cd72 100644 --- a/tests/Dotsider.Tests/PdataReaderTests.cs +++ b/tests/Dotsider.Tests/PdataReaderTests.cs @@ -7,13 +7,15 @@ namespace Dotsider.Tests; /// Tests for , the PE .pdata function-boundary fallback, using /// synthetic PE images so both the x64 and ARM64 layouts are exercised on every platform. /// +[TestClass] public class PdataReaderTests { /// /// Verifies x64 RUNTIME_FUNCTION entries yield sized boundaries, and a chained fragment is /// folded away rather than counted as its own function. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadBoundaries_Amd64_EmitsFunctionsAndFoldsChained() { var section = new byte[0x200]; @@ -27,16 +29,17 @@ public void ReadBoundaries_Amd64_EmitsFunctionsAndFoldsChained() var boundaries = PdataReader.ReadBoundaries(pe); - var only = Assert.Single(boundaries); - Assert.Equal("sub_2000", only.Name); - Assert.Equal(0x40, only.Size); - Assert.Equal(NativeSymbolKind.Boundary, Build(only).Symbols[0].Kind); + var only = Assert.ContainsSingle(boundaries); + Assert.AreEqual("sub_2000", only.Name); + Assert.AreEqual(0x40, only.Size); + Assert.AreEqual(NativeSymbolKind.Boundary, Build(only).Symbols[0].Kind); } /// /// Verifies an ARM64 packed unwind entry decodes its function length from the packed word. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadBoundaries_Arm64Packed_DecodesFunctionLength() { var section = new byte[0x200]; @@ -48,19 +51,20 @@ public void ReadBoundaries_Arm64Packed_DecodesFunctionLength() var boundaries = PdataReader.ReadBoundaries(pe); - var only = Assert.Single(boundaries); - Assert.Equal(0x40, only.Size); + var only = Assert.ContainsSingle(boundaries); + Assert.AreEqual(0x40, only.Size); } /// /// Verifies a PE without an exception directory yields no boundaries. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadBoundaries_NoExceptionDirectory_ReturnsEmpty() { var pe = SyntheticImageBuilders.BuildPe(0x8664, new byte[0x200], exceptionRva: 0, exceptionSize: 0); - Assert.Empty(PdataReader.ReadBoundaries(pe)); + Assert.IsEmpty(PdataReader.ReadBoundaries(pe)); } private static NativeSymbolInfo Build(RawNativeSymbol s) => diff --git a/tests/Dotsider.Tests/PeDirectoryReaderTests.cs b/tests/Dotsider.Tests/PeDirectoryReaderTests.cs index c70e9708..76a6e14a 100644 --- a/tests/Dotsider.Tests/PeDirectoryReaderTests.cs +++ b/tests/Dotsider.Tests/PeDirectoryReaderTests.cs @@ -11,9 +11,11 @@ namespace Dotsider.Tests; /// Windows, ELF on Linux, and Mach-O on macOS. A synthetic PE covers export shapes /// (forwarders, ordinal-only) that no sample produces. /// -[Collection("SampleAssemblies")] -public class PeDirectoryReaderTests(SampleAssemblyFixture samples) +[TestClass] +public class PeDirectoryReaderTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// The core system library name expected in the import table of the running OS. private static string CoreImportLibrary => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "kernel32" @@ -23,16 +25,17 @@ public class PeDirectoryReaderTests(SampleAssemblyFixture samples) /// /// Verifies the apphost executable's import table lists the platform's core system library. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Imports_ApphostExe_ContainsCoreLibrary() { - using var analyzer = new AssemblyAnalyzer(samples.HelloWorldExe); + using var analyzer = new AssemblyAnalyzer(Samples.HelloWorldExe); var imports = analyzer.Imports; - Assert.NotEmpty(imports); - Assert.Contains(imports, m => - m.ModuleName.Contains(CoreImportLibrary, StringComparison.OrdinalIgnoreCase)); + Assert.IsNotEmpty(imports); + Assert.Contains(m => + m.ModuleName.Contains(CoreImportLibrary, StringComparison.OrdinalIgnoreCase), imports); } /// @@ -40,74 +43,78 @@ public void Imports_ApphostExe_ContainsCoreLibrary() /// with named functions attributed to it (PE thunks, ELF versioned symbols, or /// Mach-O two-level-namespace bindings). /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Imports_NativeAotExe_ContainsCoreLibraryWithFunctions() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var imports = analyzer.Imports; - Assert.NotEmpty(imports); - Assert.Contains(imports, m => - m.ModuleName.Contains(CoreImportLibrary, StringComparison.OrdinalIgnoreCase)); + Assert.IsNotEmpty(imports); + Assert.Contains(m => + m.ModuleName.Contains(CoreImportLibrary, StringComparison.OrdinalIgnoreCase), imports); var named = imports.SelectMany(m => m.Functions).Where(f => f.Name is not null).ToList(); - Assert.NotEmpty(named); - Assert.All(named, f => Assert.Null(f.Ordinal)); + Assert.IsNotEmpty(named); + TestAssert.All(named, f => Assert.IsNull(f.Ordinal)); } /// /// Verifies a managed DLL parses without errors — its import table is empty or tiny. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Imports_ManagedDll_WellFormed() { - using var analyzer = new AssemblyAnalyzer(samples.RichLibraryDll); + using var analyzer = new AssemblyAnalyzer(Samples.RichLibraryDll); var imports = analyzer.Imports; - Assert.NotNull(imports); - Assert.All(imports, m => Assert.False(string.IsNullOrEmpty(m.ModuleName))); + Assert.IsNotNull(imports); + TestAssert.All(imports, m => Assert.IsFalse(string.IsNullOrEmpty(m.ModuleName))); } /// /// Verifies export parsing on real samples never throws — the samples export nothing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Exports_RealSamples_WellFormed() { - using var apphost = new AssemblyAnalyzer(samples.HelloWorldExe); - using var managed = new AssemblyAnalyzer(samples.RichLibraryDll); + using var apphost = new AssemblyAnalyzer(Samples.HelloWorldExe); + using var managed = new AssemblyAnalyzer(Samples.RichLibraryDll); - Assert.NotNull(apphost.Exports); - Assert.NotNull(managed.Exports); + Assert.IsNotNull(apphost.Exports); + Assert.IsNotNull(managed.Exports); } /// /// Verifies named, forwarded, and ordinal-only exports parse from a synthetic PE. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Exports_SyntheticPe_ParsesNamedForwarderAndOrdinalOnly() { using var peReader = new PEReader(new MemoryStream(BuildExportTestPe())); var exports = PeDirectoryReader.ReadExports(peReader); - Assert.Equal(3, exports.Count); + Assert.HasCount(3, exports); - var alpha = Assert.Single(exports, e => e.Name == "Alpha"); - Assert.Equal(5, alpha.Ordinal); - Assert.Null(alpha.ForwardedTo); + var alpha = Assert.ContainsSingle(e => e.Name == "Alpha", exports); + Assert.AreEqual(5, alpha.Ordinal); + Assert.IsNull(alpha.ForwardedTo); - var beta = Assert.Single(exports, e => e.Name == "Beta"); - Assert.Equal(6, beta.Ordinal); - Assert.Equal("NTDLL.RtlAllocateHeap", beta.ForwardedTo); + var beta = Assert.ContainsSingle(e => e.Name == "Beta", exports); + Assert.AreEqual(6, beta.Ordinal); + Assert.AreEqual("NTDLL.RtlAllocateHeap", beta.ForwardedTo); - var ordinalOnly = Assert.Single(exports, e => e.Name is null); - Assert.Equal(7, ordinalOnly.Ordinal); + var ordinalOnly = Assert.ContainsSingle(e => e.Name is null, exports); + Assert.AreEqual(7, ordinalOnly.Ordinal); } /// @@ -115,18 +122,19 @@ public void Exports_SyntheticPe_ParsesNamedForwarderAndOrdinalOnly() /// exports its runtime debug header; a Windows PE executable typically exports /// nothing, which is equally valid. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Exports_NativeAotExe_WellFormed() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var exports = analyzer.Exports; - Assert.NotNull(exports); - Assert.All(exports, e => Assert.False(string.IsNullOrEmpty(e.Name))); + Assert.IsNotNull(exports); + TestAssert.All(exports, e => Assert.IsFalse(string.IsNullOrEmpty(e.Name))); } /// @@ -134,59 +142,63 @@ public void Exports_NativeAotExe_WellFormed() /// cookie on Windows, where the PE load configuration directory exists. The /// directory is a PE-only structure with no ELF or Mach-O equivalent. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void LoadConfig_NativeAotExe_HasSecurityCookie() { - Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + TestSkip.Unless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "the load configuration directory is a PE-only structure"); - Assert.SkipWhen(samples.NativeAotConsoleExe is null, + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var loadConfig = analyzer.LoadConfig; - Assert.NotNull(loadConfig); - Assert.True(loadConfig.Size > 0); - Assert.NotEqual(0UL, loadConfig.SecurityCookie); - Assert.False(string.IsNullOrEmpty(loadConfig.GuardFlagsDescription)); + Assert.IsNotNull(loadConfig); + Assert.IsGreaterThan(0u, loadConfig.Size); + Assert.AreNotEqual(0UL, loadConfig.SecurityCookie); + Assert.IsFalse(string.IsNullOrEmpty(loadConfig.GuardFlagsDescription)); } /// /// Verifies the apphost executable's load configuration parses on Windows. The /// directory is a PE-only structure with no ELF or Mach-O equivalent. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void LoadConfig_ApphostExe_Parses() { - Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + TestSkip.Unless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "the load configuration directory is a PE-only structure"); - using var analyzer = new AssemblyAnalyzer(samples.HelloWorldExe); + using var analyzer = new AssemblyAnalyzer(Samples.HelloWorldExe); var loadConfig = analyzer.LoadConfig; - Assert.NotNull(loadConfig); - Assert.True(loadConfig.Size > 0); + Assert.IsNotNull(loadConfig); + Assert.IsGreaterThan(0u, loadConfig.Size); } /// /// Verifies an import directory pointing outside every section yields an empty /// result rather than throwing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Imports_UnmappedDirectory_ReturnsEmpty() { using var peReader = new PEReader( new MemoryStream(BuildExportTestPe(importDirRva: 0x5000, importDirSize: 0x100))); - Assert.Empty(PeDirectoryReader.ReadImports(peReader)); + Assert.IsEmpty(PeDirectoryReader.ReadImports(peReader)); } /// /// Verifies all directory properties degrade gracefully for a non-PE native binary. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NonPe_AllDirectories_Empty() { var elfBytes = new byte[128]; @@ -197,9 +209,9 @@ public void NonPe_AllDirectories_Empty() using var analyzer = new AssemblyAnalyzer(elfBytes, "fake.so"); - Assert.Empty(analyzer.Imports); - Assert.Empty(analyzer.Exports); - Assert.Null(analyzer.LoadConfig); + Assert.IsEmpty(analyzer.Imports); + Assert.IsEmpty(analyzer.Exports); + Assert.IsNull(analyzer.LoadConfig); } /// diff --git a/tests/Dotsider.Tests/PeMetadataViewTests.cs b/tests/Dotsider.Tests/PeMetadataViewTests.cs index 27e4c292..d28f8c1b 100644 --- a/tests/Dotsider.Tests/PeMetadataViewTests.cs +++ b/tests/Dotsider.Tests/PeMetadataViewTests.cs @@ -10,9 +10,11 @@ namespace Dotsider.Tests; /// /// Tests for Pe Metadata View. /// -[Collection("SampleAssemblies")] -public class PeMetadataViewTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class PeMetadataViewTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _hex1bApp; @@ -30,7 +32,7 @@ public class PeMetadataViewTests(SampleAssemblyFixture samples) : IDisposable _hex1bApp = new Hex1bApp( ctx => { - _state ??= new DotsiderState(_hex1bApp!, assemblyPath ?? samples.RichLibraryDll) + _state ??= new DotsiderState(_hex1bApp!, assemblyPath ?? Samples.RichLibraryDll) { CurrentTab = TabId.PeMetadata }; @@ -48,11 +50,12 @@ public class PeMetadataViewTests(SampleAssemblyFixture samples) : IDisposable /// /// Verifies pe metadata shows pe headers. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_ShowsPeHeaders() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -70,11 +73,12 @@ public async Task PeMetadata_ShowsPeHeaders() /// /// Verifies pe metadata shows sections table. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_ShowsSectionsTable() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -86,7 +90,7 @@ public async Task PeMetadata_ShowsSectionsTable() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(PeSubTabId.Sections, _state!.PeSubTab); + Assert.AreEqual(PeSubTabId.Sections, _state!.PeSubTab); cts.Cancel(); await runTask; @@ -95,11 +99,12 @@ public async Task PeMetadata_ShowsSectionsTable() /// /// Verifies pe metadata navigate to type def. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_NavigateToTypeDef() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -113,7 +118,7 @@ public async Task PeMetadata_NavigateToTypeDef() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(PeSubTabId.TypeDef, _state!.PeSubTab); + Assert.AreEqual(PeSubTabId.TypeDef, _state!.PeSubTab); cts.Cancel(); await runTask; @@ -122,11 +127,12 @@ public async Task PeMetadata_NavigateToTypeDef() /// /// Verifies pe metadata navigate to method def. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_NavigateToMethodDef() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -142,7 +148,7 @@ public async Task PeMetadata_NavigateToMethodDef() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(PeSubTabId.MethodDef, _state!.PeSubTab); + Assert.AreEqual(PeSubTabId.MethodDef, _state!.PeSubTab); cts.Cancel(); await runTask; @@ -151,11 +157,12 @@ public async Task PeMetadata_NavigateToMethodDef() /// /// Verifies pe metadata navigate to type ref. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_NavigateToTypeRef() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -174,7 +181,7 @@ public async Task PeMetadata_NavigateToTypeRef() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(PeSubTabId.TypeRef, _state!.PeSubTab); + Assert.AreEqual(PeSubTabId.TypeRef, _state!.PeSubTab); cts.Cancel(); await runTask; @@ -183,11 +190,12 @@ public async Task PeMetadata_NavigateToTypeRef() /// /// Verifies pe metadata navigate to member ref. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_NavigateToMemberRef() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -208,7 +216,7 @@ public async Task PeMetadata_NavigateToMemberRef() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(PeSubTabId.MemberRef, _state!.PeSubTab); + Assert.AreEqual(PeSubTabId.MemberRef, _state!.PeSubTab); cts.Cancel(); await runTask; @@ -217,11 +225,12 @@ public async Task PeMetadata_NavigateToMemberRef() /// /// Verifies pe metadata navigate to attributes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_NavigateToAttributes() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -244,7 +253,7 @@ public async Task PeMetadata_NavigateToAttributes() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(PeSubTabId.Attributes, _state!.PeSubTab); + Assert.AreEqual(PeSubTabId.Attributes, _state!.PeSubTab); cts.Cancel(); await runTask; @@ -253,11 +262,12 @@ public async Task PeMetadata_NavigateToAttributes() /// /// Verifies pe metadata navigate to resources. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_NavigateToResources() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -282,7 +292,7 @@ public async Task PeMetadata_NavigateToResources() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(PeSubTabId.Resources, _state!.PeSubTab); + Assert.AreEqual(PeSubTabId.Resources, _state!.PeSubTab); cts.Cancel(); await runTask; @@ -291,11 +301,12 @@ public async Task PeMetadata_NavigateToResources() /// /// Verifies pe metadata navigate to debug directory. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_NavigateToDebugDirectory() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -321,8 +332,8 @@ await auto.WaitUntilAsync( _ => _state!.PeDetailContent?.Contains("Debug Directory", StringComparison.Ordinal) == true, description: "Debug Directory detail opens"); - Assert.Equal(PeSubTabId.DebugDirectory, _state!.PeSubTab); - Assert.Contains("Payload:", _state.PeDetailContent); + Assert.AreEqual(PeSubTabId.DebugDirectory, _state!.PeSubTab); + Assert.Contains("Payload:", _state.PeDetailContent!); cts.Cancel(); await runTask; @@ -331,11 +342,12 @@ await auto.WaitUntilAsync( /// /// Verifies pe metadata shows clr header fields. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_ShowsClrHeaderFields() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -354,11 +366,12 @@ public async Task PeMetadata_ShowsClrHeaderFields() /// /// Verifies pe metadata shows pe header fields. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_ShowsPeHeaderFields() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -377,11 +390,12 @@ public async Task PeMetadata_ShowsPeHeaderFields() /// /// Verifies pe metadata enter opens detail popup. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_EnterOpensDetailPopup() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -396,7 +410,7 @@ public async Task PeMetadata_EnterOpensDetailPopup() .Build() .ApplyAsync(terminal, cts.Token); - Assert.NotNull(_state!.PeDetailContent); + Assert.IsNotNull(_state!.PeDetailContent); Assert.Contains("Section:", _state.PeDetailContent); cts.Cancel(); @@ -406,11 +420,12 @@ public async Task PeMetadata_EnterOpensDetailPopup() /// /// Verifies pe metadata escape closes detail popup. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_EscapeClosesDetailPopup() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -428,7 +443,7 @@ public async Task PeMetadata_EscapeClosesDetailPopup() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Null(_state!.PeDetailContent); + Assert.IsNull(_state!.PeDetailContent); cts.Cancel(); await runTask; @@ -437,11 +452,12 @@ public async Task PeMetadata_EscapeClosesDetailPopup() /// /// Verifies pe metadata type def detail popup. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_TypeDefDetailPopup() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -462,7 +478,7 @@ public async Task PeMetadata_TypeDefDetailPopup() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Null(_state!.PeDetailContent); + Assert.IsNull(_state!.PeDetailContent); cts.Cancel(); await runTask; @@ -471,11 +487,12 @@ public async Task PeMetadata_TypeDefDetailPopup() /// /// Verifies pe metadata escape during search does not crash. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_EscapeDuringSearchDoesNotCrash() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -491,7 +508,7 @@ public async Task PeMetadata_EscapeDuringSearchDoesNotCrash() .Build() .ApplyAsync(terminal, cts.Token); - Assert.False(_state!.Search[TabId.PeMetadata].IsActive); + Assert.IsFalse(_state!.Search[TabId.PeMetadata].IsActive); cts.Cancel(); await runTask; @@ -500,11 +517,12 @@ public async Task PeMetadata_EscapeDuringSearchDoesNotCrash() /// /// Verifies pe metadata arrow and enter work after detail dismissed. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_ArrowAndEnterWorkAfterDetailDismissed() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -524,7 +542,7 @@ public async Task PeMetadata_ArrowAndEnterWorkAfterDetailDismissed() .Build() .ApplyAsync(terminal, cts.Token); - Assert.NotNull(_state!.PeDetailContent); + Assert.IsNotNull(_state!.PeDetailContent); cts.Cancel(); await runTask; @@ -533,11 +551,12 @@ public async Task PeMetadata_ArrowAndEnterWorkAfterDetailDismissed() /// /// Verifies pe metadata enter works after search dismissed. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_EnterWorksAfterSearchDismissed() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -556,7 +575,7 @@ public async Task PeMetadata_EnterWorksAfterSearchDismissed() .Build() .ApplyAsync(terminal, cts.Token); - Assert.NotNull(_state!.PeDetailContent); + Assert.IsNotNull(_state!.PeDetailContent); cts.Cancel(); await runTask; @@ -565,11 +584,12 @@ public async Task PeMetadata_EnterWorksAfterSearchDismissed() /// /// Verifies pe metadata enter works after search with results. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_EnterWorksAfterSearchWithResults() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -591,7 +611,7 @@ public async Task PeMetadata_EnterWorksAfterSearchWithResults() .Build() .ApplyAsync(terminal, cts.Token); - Assert.NotNull(_state!.PeDetailContent); + Assert.IsNotNull(_state!.PeDetailContent); cts.Cancel(); await runTask; @@ -600,11 +620,12 @@ public async Task PeMetadata_EnterWorksAfterSearchWithResults() /// /// Verifies pe metadata left arrow does not go below zero. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_LeftArrowDoesNotGoBelowZero() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -617,7 +638,7 @@ public async Task PeMetadata_LeftArrowDoesNotGoBelowZero() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(PeSubTabId.Sections, _state!.PeSubTab); + Assert.AreEqual(PeSubTabId.Sections, _state!.PeSubTab); cts.Cancel(); await runTask; @@ -626,11 +647,12 @@ public async Task PeMetadata_LeftArrowDoesNotGoBelowZero() /// /// Verifies pe metadata section detail popup shows colored labels and hex values. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_SectionDetailPopup_ShowsColoredLabelsAndHexValues() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -657,11 +679,12 @@ public async Task PeMetadata_SectionDetailPopup_ShowsColoredLabelsAndHexValues() /// /// Verifies pe metadata type def detail popup shows token and attributes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_TypeDefDetailPopup_ShowsTokenAndAttributes() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -688,11 +711,12 @@ public async Task PeMetadata_TypeDefDetailPopup_ShowsTokenAndAttributes() /// /// Verifies pe metadata method def detail popup shows signature and rva. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_MethodDefDetailPopup_ShowsSignatureAndRva() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -730,14 +754,15 @@ public async Task PeMetadata_MethodDefDetailPopup_ShowsSignatureAndRva() /// executable — PE imports on Windows, ELF needed libraries on Linux, Mach-O /// dylibs on macOS. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_NativeAot_ImportsTab_ShowsModules() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var (terminal, app) = CreateDotsiderApp(samples.NativeAotConsoleExe); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var (terminal, app) = CreateDotsiderApp(Samples.NativeAotConsoleExe); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -760,10 +785,10 @@ await builder .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(PeSubTabId.Imports, _state!.PeSubTab); - Assert.NotEmpty(_state.Analyzer.Imports); - Assert.Contains(_state.Analyzer.Imports, m => - m.ModuleName.Contains(CoreImportLibrary, StringComparison.OrdinalIgnoreCase)); + Assert.AreEqual(PeSubTabId.Imports, _state!.PeSubTab); + Assert.IsNotEmpty(_state.Analyzer.Imports); + Assert.Contains(m => + m.ModuleName.Contains(CoreImportLibrary, StringComparison.OrdinalIgnoreCase), _state.Analyzer.Imports); cts.Cancel(); await runTask; @@ -779,14 +804,15 @@ private string FirstModulePrefix() /// /// Verifies the Imports detail popup opens on Enter for a Native AOT executable. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_NativeAot_ImportsDetailPopup_OpensOnEnter() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var (terminal, app) = CreateDotsiderApp(samples.NativeAotConsoleExe); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var (terminal, app) = CreateDotsiderApp(Samples.NativeAotConsoleExe); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -820,16 +846,17 @@ await builder /// /// Verifies the Load Config sub-tab shows parsed fields for a Native AOT executable. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_NativeAot_LoadConfigTab_ShowsFields() { - Assert.SkipUnless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), + TestSkip.Unless(RuntimeInformation.IsOSPlatform(OSPlatform.Windows), "the load configuration directory is a PE-only structure"); - Assert.SkipWhen(samples.NativeAotConsoleExe is null, + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var (terminal, app) = CreateDotsiderApp(samples.NativeAotConsoleExe); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var (terminal, app) = CreateDotsiderApp(Samples.NativeAotConsoleExe); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -849,8 +876,8 @@ await builder .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(PeSubTabId.LoadConfig, _state!.PeSubTab); - Assert.NotNull(_state.Analyzer.LoadConfig); + Assert.AreEqual(PeSubTabId.LoadConfig, _state!.PeSubTab); + Assert.IsNotNull(_state.Analyzer.LoadConfig); cts.Cancel(); await runTask; @@ -860,11 +887,12 @@ await builder /// Verifies a managed DLL can navigate through the Imports, Exports, and Load /// Config sub-tabs without crashing even when they are empty. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_ManagedDll_NewSubTabs_NoCrash() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -884,7 +912,7 @@ await builder .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(PeSubTabId.LoadConfig, _state!.PeSubTab); + Assert.AreEqual(PeSubTabId.LoadConfig, _state!.PeSubTab); cts.Cancel(); await runTask; @@ -894,13 +922,14 @@ await builder /// Verifies the R2R Sections sub-tab shows the ReadyToRun section table for a Native /// AOT binary on every platform. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_NativeAot_RtrSectionsTab_ShowsSections() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var (terminal, app) = CreateDotsiderApp(samples.NativeAotConsoleExe); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var (terminal, app) = CreateDotsiderApp(Samples.NativeAotConsoleExe); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -920,8 +949,8 @@ await builder .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(PeSubTabId.RtrSections, _state!.PeSubTab); - Assert.NotEmpty(_state.Analyzer.ReadyToRunSections); + Assert.AreEqual(PeSubTabId.RtrSections, _state!.PeSubTab); + Assert.IsNotEmpty(_state.Analyzer.ReadyToRunSections); cts.Cancel(); await runTask; @@ -931,13 +960,14 @@ await builder /// Verifies the AOT Types sub-tab shows recovered types, and the detail popup lists a /// type's methods. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_NativeAot_AotTypesTab_ShowsTypesAndMethods() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var (terminal, app) = CreateDotsiderApp(samples.NativeAotConsoleExe); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var (terminal, app) = CreateDotsiderApp(Samples.NativeAotConsoleExe); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -961,8 +991,8 @@ await builder .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(PeSubTabId.AotTypes, _state!.PeSubTab); - Assert.NotEmpty(_state.Analyzer.RecoveredTypes); + Assert.AreEqual(PeSubTabId.AotTypes, _state!.PeSubTab); + Assert.IsNotEmpty(_state.Analyzer.RecoveredTypes); Assert.Contains("Methods", _state.PeDetailContent!); cts.Cancel(); @@ -973,14 +1003,15 @@ await builder /// Verifies the Symbols sub-tab lists the Native AOT binary's symbols with the address /// column rendered. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_NativeAot_SymbolsTab_ShowsSymbols() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var (terminal, app) = CreateDotsiderApp(samples.NativeAotConsoleExe); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var (terminal, app) = CreateDotsiderApp(Samples.NativeAotConsoleExe); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1000,9 +1031,9 @@ await builder .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(PeSubTabId.Symbols, _state!.PeSubTab); - Assert.NotNull(_state.Analyzer.NativeSymbols); - Assert.NotEmpty(_state.Analyzer.NativeSymbols!.Symbols); + Assert.AreEqual(PeSubTabId.Symbols, _state!.PeSubTab); + Assert.IsNotNull(_state.Analyzer.NativeSymbols); + Assert.IsNotEmpty(_state.Analyzer.NativeSymbols!.Symbols); cts.Cancel(); await runTask; @@ -1012,14 +1043,15 @@ await builder /// Verifies the Symbols detail popup opens on Enter, carrying the mangled name and the /// symbol source line. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_NativeAot_SymbolsDetailPopup_OpensOnEnter() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var (terminal, app) = CreateDotsiderApp(samples.NativeAotConsoleExe); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var (terminal, app) = CreateDotsiderApp(Samples.NativeAotConsoleExe); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1054,11 +1086,12 @@ await builder /// Verifies the Symbols sub-tab renders empty for a managed assembly instead of crashing — /// managed binaries have no native symbols. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_Managed_SymbolsTab_EmptyNoCrash() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1078,8 +1111,8 @@ await builder .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(PeSubTabId.Symbols, _state!.PeSubTab); - Assert.Null(_state.Analyzer.NativeSymbols); + Assert.AreEqual(PeSubTabId.Symbols, _state!.PeSubTab); + Assert.IsNull(_state.Analyzer.NativeSymbols); cts.Cancel(); await runTask; @@ -1088,13 +1121,14 @@ await builder /// /// Verifies raw WebAssembly modules reuse the metadata sub-tabs with Wasm-specific tables. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_Wasm_SubTabsRenderWasmTables() { var wasmPath = GetWasmNativePath(); var (terminal, app) = CreateDotsiderApp(wasmPath); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1122,8 +1156,8 @@ await builder .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(PeSubTabId.Symbols, _state!.PeSubTab); - Assert.Equal(BinaryKind.Wasm, _state.Analyzer.BinaryKind); + Assert.AreEqual(PeSubTabId.Symbols, _state!.PeSubTab); + Assert.AreEqual(BinaryKind.Wasm, _state.Analyzer.BinaryKind); cts.Cancel(); await runTask; @@ -1137,12 +1171,12 @@ private Hex1bTerminalInputSequenceBuilder ExpectNextWasmSubTab( .WaitUntil(s => s.ContainsText(label), TimeSpan.FromSeconds(10)) .WaitUntil(s => s.ContainsText(tableText), TimeSpan.FromSeconds(10)); - private string GetWasmNativePath() + private static string GetWasmNativePath() { - Assert.SkipWhen(samples.WasmConsoleNativeWasm is null && samples.ReadyToRunConsoleWasmNativeWasm is null, + TestSkip.When(Samples.WasmConsoleNativeWasm is null && Samples.ReadyToRunConsoleWasmNativeWasm is null, "browser-wasm sample publish did not produce dotnet.native.wasm on this leg."); - return samples.WasmConsoleNativeWasm ?? samples.ReadyToRunConsoleWasmNativeWasm!; + return Samples.WasmConsoleNativeWasm ?? Samples.ReadyToRunConsoleWasmNativeWasm!; } /// @@ -1150,10 +1184,10 @@ private string GetWasmNativePath() /// public void Dispose() { + GC.SuppressFinalize(this); _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/PeerCredentialTests.cs b/tests/Dotsider.Tests/PeerCredentialTests.cs index 83ecd833..b5bad6b3 100644 --- a/tests/Dotsider.Tests/PeerCredentialTests.cs +++ b/tests/Dotsider.Tests/PeerCredentialTests.cs @@ -17,14 +17,18 @@ namespace Dotsider.Tests; /// implementations are tested positively /// (same-user returns true). /// -[Collection("SampleAssemblies")] -public class PeerCredentialTests(SampleAssemblyFixture samples) : IAsyncDisposable +[TestClass] +public class PeerCredentialTests : IAsyncDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _app; private DotsiderState? _state; private DotsiderDiagnosticsListener? _listener; + private CancellationTokenSource? _appCts; + private Task? _appTask; private async Task StartTuiWithDiagnosticsAsync(CancellationToken ct) { @@ -40,7 +44,7 @@ private async Task StartTuiWithDiagnosticsAsync(CancellationToken ct) _app = new Hex1bApp( ctx => { - _state ??= new DotsiderState(_app!, samples.HelloWorldDll, pendingMutations); + _state ??= new DotsiderState(_app!, Samples.HelloWorldDll, pendingMutations); var dotsiderApp = new DotsiderApp(_state); return Task.FromResult(dotsiderApp.Build(ctx)); }, @@ -48,12 +52,13 @@ private async Task StartTuiWithDiagnosticsAsync(CancellationToken ct) { WorkloadAdapter = _workload, EnableInputCoalescing = false - }); + }); _listener = new DotsiderDiagnosticsListener(() => _state); - _listener.StartListening(overridePid: Random.Shared.Next(100_000, 999_999)); + _listener.StartListening(overridePid: TestSocketIds.NextPid()); - _ = _app.RunAsync(ct); + _appCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _appTask = _app.RunAsync(_appCts.Token); await Task.Delay(100, ct); await TestHelpers.WaitUntilAsync( @@ -69,35 +74,44 @@ await TestHelpers.WaitUntilAsync( public async ValueTask DisposeAsync() { GC.SuppressFinalize(this); + _appCts?.Cancel(); if (_listener is not null) await _listener.DisposeAsync(); + if (_appTask is not null) + { + try { await _appTask; } + catch (OperationCanceledException) { } + } _state?.Dispose(); _app?.Dispose(); if (_terminal is not null) await _terminal.DisposeAsync(); + _appCts?.Dispose(); } /// /// Verifies same user connection accepted. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SameUser_ConnectionAccepted() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var socketPath = await StartTuiWithDiagnosticsAsync(ct); // Normal same-user connection should succeed var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "assembly-info" }, ct); - Assert.True(response.Success); + Assert.IsTrue(response.Success); } /// /// Verifies platform verifier returns true for same user. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PlatformVerifier_ReturnsTrueForSameUser() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var socketPath = await StartTuiWithDiagnosticsAsync(ct); // Multiple sequential requests all succeed — verifier consistently passes @@ -105,17 +119,18 @@ public async Task PlatformVerifier_ReturnsTrueForSameUser() { var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "assembly-info" }, ct); - Assert.True(response.Success); + Assert.IsTrue(response.Success); } } /// /// Verifies peer rejection sends error response. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeerRejection_SendsErrorResponse() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var socketPath = await StartTuiWithDiagnosticsAsync(ct); _listener!.ForceRejectPeers = true; @@ -125,18 +140,19 @@ public async Task PeerRejection_SendsErrorResponse() DotsiderJsonOptions.Default), ct); var response = JsonSerializer.Deserialize(rawResponse, DotsiderJsonOptions.Default); - Assert.NotNull(response); - Assert.False(response.Success); + Assert.IsNotNull(response); + Assert.IsFalse(response.Success); Assert.Contains("peer", response.Error!, StringComparison.OrdinalIgnoreCase); } /// /// Verifies peer rejection response contains version. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeerRejection_ResponseContainsVersion() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var socketPath = await StartTuiWithDiagnosticsAsync(ct); _listener!.ForceRejectPeers = true; @@ -146,6 +162,6 @@ public async Task PeerRejection_ResponseContainsVersion() DotsiderJsonOptions.Default), ct); var doc = JsonDocument.Parse(rawResponse); - Assert.Equal(1, doc.RootElement.GetProperty("v").GetInt32()); + Assert.AreEqual(1, doc.RootElement.GetProperty("v").GetInt32()); } } diff --git a/tests/Dotsider.Tests/PreIlcAnalyzerTests.cs b/tests/Dotsider.Tests/PreIlcAnalyzerTests.cs index 908b8585..b3871720 100644 --- a/tests/Dotsider.Tests/PreIlcAnalyzerTests.cs +++ b/tests/Dotsider.Tests/PreIlcAnalyzerTests.cs @@ -8,9 +8,11 @@ namespace Dotsider.Tests; /// lifecycle, generation-guarded index builds, sidecar path fallbacks, and correlation /// against the real fixture. /// -[Collection("SampleAssemblies")] -public class PreIlcAnalyzerTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class PreIlcAnalyzerTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private readonly List _tempFiles = []; private string NewTempDir() @@ -22,121 +24,129 @@ private string NewTempDir() } /// Verifies the probe is gated on the Native AOT binary kind. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PreIlcSidecars_ManagedAssemblyAndApphost_Null() { - using var managed = new AssemblyAnalyzer(samples.HelloWorldDll); - Assert.Null(managed.PreIlcSidecars); - Assert.Null(managed.AttachPreIlcCompanions()); + using var managed = new AssemblyAnalyzer(Samples.HelloWorldDll); + Assert.IsNull(managed.PreIlcSidecars); + Assert.IsNull(managed.AttachPreIlcCompanions()); - using var apphost = new AssemblyAnalyzer(samples.HelloWorldExe); - Assert.Null(apphost.PreIlcSidecars); + using var apphost = new AssemblyAnalyzer(Samples.HelloWorldExe); + Assert.IsNull(apphost.PreIlcSidecars); } /// Verifies the fixture AOT exe probes its full sidecar set. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PreIlcSidecars_FixtureAotExe_Found() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var sidecars = analyzer.PreIlcSidecars; - Assert.NotNull(sidecars); - Assert.True(sidecars!.HasAttachableCompanion); - Assert.Equal(PreIlcAssemblyOrigin.IlcResponseFile, sidecars.Origin); + Assert.IsNotNull(sidecars); + Assert.IsTrue(sidecars!.HasAttachableCompanion); + Assert.AreEqual(PreIlcAssemblyOrigin.IlcResponseFile, sidecars.Origin); } /// Verifies attach is idempotent and the set carries readable metadata. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AttachPreIlcCompanions_Idempotent() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var set = analyzer.AttachPreIlcCompanions(); - Assert.NotNull(set); - Assert.Same(set, analyzer.AttachPreIlcCompanions()); - Assert.Same(set, analyzer.PreIlcCompanions); - Assert.True(set!.Root.HasMetadata); - Assert.Equal("NativeAotConsole", set.Root.AssemblyName); - Assert.Same(set.Root, set.All[0]); + Assert.IsNotNull(set); + Assert.AreSame(set, analyzer.AttachPreIlcCompanions()); + Assert.AreSame(set, analyzer.PreIlcCompanions); + Assert.IsTrue(set!.Root.HasMetadata); + Assert.AreEqual("NativeAotConsole", set.Root.AssemblyName); + Assert.AreSame(set.Root, set.All[0]); } /// Verifies attach returns null when nothing attachable was probed. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AttachPreIlcCompanions_NoSidecars_Null() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); var dir = NewTempDir(); var exe = Path.Combine(dir, "NativeAotConsole.exe"); - File.Copy(samples.NativeAotConsoleExe!, exe); + File.Copy(Samples.NativeAotConsoleExe!, exe); using var analyzer = new AssemblyAnalyzer(exe); - Assert.Null(analyzer.PreIlcSidecars); - Assert.Null(analyzer.AttachPreIlcCompanions()); - Assert.Null(analyzer.ManagedNativeIndex); + Assert.IsNull(analyzer.PreIlcSidecars); + Assert.IsNull(analyzer.AttachPreIlcCompanions()); + Assert.IsNull(analyzer.ManagedNativeIndex); } /// Verifies detach disposes the companions and drops the index. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DetachPreIlcCompanions_DisposesSetAndDropsIndex() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var set = analyzer.AttachPreIlcCompanions(); - Assert.NotNull(set); + Assert.IsNotNull(set); analyzer.DetachPreIlcCompanions(); - Assert.Null(analyzer.PreIlcCompanions); - Assert.Null(analyzer.ManagedNativeIndex); - Assert.Throws(() => set!.Root.TypeDefs); + Assert.IsNull(analyzer.PreIlcCompanions); + Assert.IsNull(analyzer.ManagedNativeIndex); + Assert.ThrowsExactly(() => set!.Root.TypeDefs); } /// Verifies disposing the owner disposes the attached companions transitively. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Dispose_DisposesAttachedCompanions() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var set = analyzer.AttachPreIlcCompanions(); - Assert.NotNull(set); + Assert.IsNotNull(set); analyzer.Dispose(); - Assert.Throws(() => set!.Root.MethodDefs); + Assert.ThrowsExactly(() => set!.Root.MethodDefs); } /// Verifies the index is null before attach and builds once after. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ManagedNativeIndex_BuildsAfterAttach() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); - Assert.Null(analyzer.ManagedNativeIndex); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); + Assert.IsNull(analyzer.ManagedNativeIndex); analyzer.AttachPreIlcCompanions(); var index = analyzer.ManagedNativeIndex; - Assert.NotNull(index); - Assert.Same(index, analyzer.ManagedNativeIndex); - Assert.True(index!.Methods.Count > 0); + Assert.IsNotNull(index); + Assert.AreSame(index, analyzer.ManagedNativeIndex); + Assert.IsGreaterThan(0, index!.Methods.Count); } /// Verifies a stale build after detach never publishes; the reader never throws while the analyzer lives. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ManagedNativeIndex_RacingDetach_NeverPublishesStale() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var reader = Task.Run(() => { @@ -147,41 +157,43 @@ public async Task ManagedNativeIndex_RacingDetach_NeverPublishesStale() for (var i = 0; i < 5; i++) { analyzer.AttachPreIlcCompanions(); - await Task.Delay(50, TestContext.Current.CancellationToken); + await Task.Delay(50, CancellationToken.None); analyzer.DetachPreIlcCompanions(); } await cts.CancelAsync(); await reader; - Assert.Null(analyzer.PreIlcCompanions); - Assert.Null(analyzer.ManagedNativeIndex); + Assert.IsNull(analyzer.PreIlcCompanions); + Assert.IsNull(analyzer.ManagedNativeIndex); } /// Verifies a build racing the owner's dispose abandons cleanly (returns or ODE only). - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ManagedNativeIndex_RacingDispose_AbandonsCleanly() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); analyzer.AttachPreIlcCompanions(); var build = Task.Run(() => { try { _ = analyzer.ManagedNativeIndex; } catch (ObjectDisposedException) { /* documented outcome */ } - }, TestContext.Current.CancellationToken); + }, CancellationToken.None); analyzer.Dispose(); await build; } /// Verifies MstatPath/DgmlPath fall back to the obj tree, sibling wins for mstat, codegen-first for DGML. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void MstatAndDgmlPaths_ObjTreeFallbackAndCodegenPrecedence() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); var root = NewTempDir(); var exeDir = Path.Combine(root, "Proj", "bin", "Release", "net10.0", "win-x64", "publish"); @@ -190,7 +202,7 @@ public void MstatAndDgmlPaths_ObjTreeFallbackAndCodegenPrecedence() Directory.CreateDirectory(nativeDir); var exe = Path.Combine(exeDir, "NativeAotConsole.exe"); - File.Copy(samples.NativeAotConsoleExe!, exe); + File.Copy(Samples.NativeAotConsoleExe!, exe); var objMstat = Path.Combine(nativeDir, "NativeAotConsole.mstat"); File.WriteAllBytes(objMstat, [1]); @@ -199,82 +211,86 @@ public void MstatAndDgmlPaths_ObjTreeFallbackAndCodegenPrecedence() File.WriteAllBytes(objCodegen, [1]); using var analyzer = new AssemblyAnalyzer(exe); - Assert.Equal(objMstat, analyzer.MstatPath); - Assert.Equal(objCodegen, analyzer.DgmlPath); // codegen-first across locations + Assert.AreEqual(objMstat, analyzer.MstatPath); + Assert.AreEqual(objCodegen, analyzer.DgmlPath); // codegen-first across locations var siblingMstat = Path.Combine(exeDir, "NativeAotConsole.mstat"); File.WriteAllBytes(siblingMstat, [1]); using var analyzer2 = new AssemblyAnalyzer(exe); - Assert.Equal(siblingMstat, analyzer2.MstatPath); + Assert.AreEqual(siblingMstat, analyzer2.MstatPath); } /// Verifies sidecar stems strip library extensions: a Native AOT .dll finds its mstat. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindSidecar_LibraryStem_FindsMstat() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); var dir = NewTempDir(); var lib = Path.Combine(dir, "SomeAotLib.dll"); - File.Copy(samples.NativeAotConsoleExe!, lib); // AOT by content, library by name + File.Copy(Samples.NativeAotConsoleExe!, lib); // AOT by content, library by name var mstat = Path.Combine(dir, "SomeAotLib.mstat"); File.WriteAllBytes(mstat, [1]); using var analyzer = new AssemblyAnalyzer(lib); - Assert.Equal(BinaryKind.NativeAot, analyzer.BinaryKind); - Assert.Equal(mstat, analyzer.MstatPath); + Assert.AreEqual(BinaryKind.NativeAot, analyzer.BinaryKind); + Assert.AreEqual(mstat, analyzer.MstatPath); } /// Verifies the fixture Greeter correlations end to end, including ctor and accessor. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ManagedNativeIndex_FixtureGreeterCorrelations() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); analyzer.AttachPreIlcCompanions(); var index = analyzer.ManagedNativeIndex; - Assert.NotNull(index); + Assert.IsNotNull(index); var greeter = index!.Methods.Where(m => m.Method.DeclaringType == "Greeter").ToList(); - Assert.NotEmpty(greeter); + Assert.IsNotEmpty(greeter); - var describe = Assert.Single(greeter, m => m.Method.Name == "Describe"); - Assert.Equal(MethodCorrelationStatus.CorrelatedExact, describe.Status); - Assert.True(describe.NativeSize > 0); + var describe = Assert.ContainsSingle(m => m.Method.Name == "Describe", greeter); + Assert.AreEqual(MethodCorrelationStatus.CorrelatedExact, describe.Status); + Assert.IsGreaterThan(0, describe.NativeSize); var greets = greeter.Where(m => m.Method.Name == "Greet").ToList(); - Assert.Equal(2, greets.Count); - Assert.All(greets, g => Assert.Equal(MethodCorrelationStatus.CorrelatedAmbiguous, g.Status)); - Assert.All(greets, g => Assert.Equal(0, g.NativeSize)); - Assert.True(greets[0].SharedCandidateSize > 0); + Assert.HasCount(2, greets); + TestAssert.All(greets, g => Assert.AreEqual(MethodCorrelationStatus.CorrelatedAmbiguous, g.Status)); + TestAssert.All(greets, g => Assert.AreEqual(0, g.NativeSize)); + Assert.IsGreaterThan(0, greets[0].SharedCandidateSize); - var ctor = Assert.Single(greeter, m => m.Method.Name == ".ctor"); - Assert.NotEqual(MethodCorrelationStatus.NotInNativeImage, ctor.Status); + var ctor = Assert.ContainsSingle(m => m.Method.Name == ".ctor", greeter); + Assert.AreNotEqual(MethodCorrelationStatus.NotInNativeImage, ctor.Status); - var getName = Assert.Single(greeter, m => m.Method.Name == "get_Name"); - Assert.NotEqual(MethodCorrelationStatus.NotInNativeImage, getName.Status); + var getName = Assert.ContainsSingle(m => m.Method.Name == "get_Name", greeter); + Assert.AreNotEqual(MethodCorrelationStatus.NotInNativeImage, getName.Status); - var never = Assert.Single(greeter, m => m.Method.Name == "NeverCalled"); - Assert.Equal(MethodCorrelationStatus.NotInNativeImage, never.Status); + var never = Assert.ContainsSingle(m => m.Method.Name == "NeverCalled", greeter); + Assert.AreEqual(MethodCorrelationStatus.NotInNativeImage, never.Status); var withSymbol = greeter.First(m => m.NativeSymbols.Count > 0); var reverse = index.FindByAddress(withSymbol.NativeSymbols[0].VirtualAddress); - Assert.NotNull(reverse); - Assert.Equal("Greeter", reverse!.Method.DeclaringType); + Assert.IsNotNull(reverse); + Assert.AreEqual("Greeter", reverse!.Method.DeclaringType); } /// Verifies the ownership contract is structural: no public disposal surface at all. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PreIlcCompanionSet_NoPublicDisposal() { - Assert.False(typeof(IDisposable).IsAssignableFrom(typeof(PreIlcCompanionSet))); - Assert.Null(typeof(PreIlcCompanionSet).GetMethod("Dispose")); + Assert.IsFalse(typeof(IDisposable).IsAssignableFrom(typeof(PreIlcCompanionSet))); + Assert.IsNull(typeof(PreIlcCompanionSet).GetMethod("Dispose")); } /// Disposes test resources created during the run. public void Dispose() { + GC.SuppressFinalize(this); foreach (var path in _tempFiles) { try @@ -284,6 +300,5 @@ public void Dispose() } catch { /* best effort */ } } - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/PreIlcIlInspectorTests.cs b/tests/Dotsider.Tests/PreIlcIlInspectorTests.cs index d4c4fb61..5f6cd1db 100644 --- a/tests/Dotsider.Tests/PreIlcIlInspectorTests.cs +++ b/tests/Dotsider.Tests/PreIlcIlInspectorTests.cs @@ -11,9 +11,11 @@ namespace Dotsider.Tests; /// attached: the managed tree replaces the native tree, t toggles between them, and /// selecting a correlated method populates the native pair pane. /// -[Collection("SampleAssemblies")] -public class PreIlcIlInspectorTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class PreIlcIlInspectorTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private const string DialogTitle = "Native AOT Sidecars Detected"; private Hex1bAppWorkloadAdapter? _workload; @@ -21,10 +23,11 @@ public class PreIlcIlInspectorTests(SampleAssemblyFixture samples) : IDisposable private Hex1bApp? _hex1bApp; private DotsiderState? _state; private CancellationTokenSource? _cts; + private Task? _runTask; private (Hex1bTerminal terminal, Hex1bApp app, CancellationToken ct) CreateDotsiderApp(string path, int width, int height) { - _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + _cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); _workload = new Hex1bAppWorkloadAdapter(); _terminal = Hex1bTerminal.CreateBuilder() .WithWorkload(_workload) @@ -45,8 +48,8 @@ public class PreIlcIlInspectorTests(SampleAssemblyFixture samples) : IDisposable private async Task AttachAndOpenIlAsync(int width = 160, int height = 40) { - var (terminal, app, ct) = CreateDotsiderApp(samples.NativeAotConsoleExe!, width, height); - _ = app.RunAsync(ct); + var (terminal, app, ct) = CreateDotsiderApp(Samples.NativeAotConsoleExe!, width, height); + _runTask = app.RunAsync(ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); await auto.WaitUntilAlternateScreenAsync(); @@ -61,14 +64,15 @@ await auto.WaitUntilAsync(_ => _state!.CurrentTab == TabId.IlInspector, } /// Attached, the IL tree defaults to the managed companion tree and lists its types. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Attached_IlTab_DefaultsToManagedTree() { - Assert.SkipWhen(samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); await AttachAndOpenIlAsync(); - Assert.False(Views.IlInspectorView.IsNativeTreeMode(_state!)); + Assert.IsFalse(Views.IlInspectorView.IsNativeTreeMode(_state!)); // The managed type tree is visible (Program and Greeter are the sample's types). await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.ContainsText("Greeter") || s.ContainsText("Program"), TimeSpan.FromSeconds(10)) @@ -79,30 +83,32 @@ public async Task Attached_IlTab_DefaultsToManagedTree() } /// The t key toggles the tree between managed and native and back. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Attached_T_TogglesTreeMode() { - Assert.SkipWhen(samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); var auto = await AttachAndOpenIlAsync(); - Assert.False(_state!.IlAotTreeNativeView); + Assert.IsFalse(_state!.IlAotTreeNativeView); await auto.KeyAsync(Hex1bKey.T, ct: _cts!.Token); await auto.WaitUntilAsync(_ => _state!.IlAotTreeNativeView, description: "toggled to native tree"); - Assert.True(Views.IlInspectorView.IsNativeTreeMode(_state)); + Assert.IsTrue(Views.IlInspectorView.IsNativeTreeMode(_state)); await auto.KeyAsync(Hex1bKey.T, ct: _cts!.Token); await auto.WaitUntilAsync(_ => !_state!.IlAotTreeNativeView, description: "toggled back to managed tree"); - Assert.False(Views.IlInspectorView.IsNativeTreeMode(_state)); + Assert.IsFalse(Views.IlInspectorView.IsNativeTreeMode(_state)); _cts!.Cancel(); } /// Selecting a correlated method populates the native pair pane beside the IL. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Attached_SelectCorrelatedMethod_PopulatesPairNative() { - Assert.SkipWhen(samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); // Wide terminal so the right pane splits (above the narrow-collapse threshold). var auto = await AttachAndOpenIlAsync(width: 200, height: 50); @@ -114,7 +120,7 @@ public async Task Attached_SelectCorrelatedMethod_PopulatesPairNative() m.Status == MethodCorrelationStatus.CorrelatedExact && m.NativeSymbols.Count > 0 && m.NativeSymbols[0].FileOffset is not null); - Assert.SkipWhen(correlated is null, "no exact correlation with a native symbol on this leg"); + TestSkip.When(correlated is null, "no exact correlation with a native symbol on this leg"); var owner = _state.Analyzer.PreIlcCompanions!.FindByAssemblyName(correlated!.AssemblyName); var ownerArg = owner is not null && !ReferenceEquals(owner, _state.Analyzer.PreIlcCompanions!.Root) @@ -125,19 +131,20 @@ public async Task Attached_SelectCorrelatedMethod_PopulatesPairNative() await auto.WaitUntilAsync(_ => _state!.IlPairNativeEditorState is not null, description: "pair native pane populated"); - Assert.NotNull(_state.IlPairNativeEditorState); + Assert.IsNotNull(_state.IlPairNativeEditorState); _cts!.Cancel(); } /// Declined, the IL tree stays the native function tree. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Declined_IlTab_ShowsNativeTree() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var (terminal, app, ct) = CreateDotsiderApp(samples.NativeAotConsoleExe!, 160, 40); - _ = app.RunAsync(ct); + var (terminal, app, ct) = CreateDotsiderApp(Samples.NativeAotConsoleExe!, 160, 40); + _runTask = app.RunAsync(ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); await auto.WaitUntilAlternateScreenAsync(); @@ -148,7 +155,7 @@ public async Task Declined_IlTab_ShowsNativeTree() await auto.WaitUntilAsync(s => s.ContainsText("(functions)") || s.ContainsText("(runtime)"), description: "native function tree rendered"); - Assert.True(Views.IlInspectorView.IsNativeTreeMode(_state!)); + Assert.IsTrue(Views.IlInspectorView.IsNativeTreeMode(_state!)); _cts!.Cancel(); } @@ -158,6 +165,9 @@ public void Dispose() { GC.SuppressFinalize(this); _cts?.Cancel(); + try { _runTask?.Wait(TimeSpan.FromSeconds(5)); } + catch (AggregateException ex) when (ex.InnerExceptions.All(static e => e is OperationCanceledException)) { } + catch (OperationCanceledException) { } _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); diff --git a/tests/Dotsider.Tests/PreIlcOfferTests.cs b/tests/Dotsider.Tests/PreIlcOfferTests.cs index 3771ea59..423f1553 100644 --- a/tests/Dotsider.Tests/PreIlcOfferTests.cs +++ b/tests/Dotsider.Tests/PreIlcOfferTests.cs @@ -10,9 +10,11 @@ namespace Dotsider.Tests; /// Native AOT binary, Enter attaches alongside (never replaces), Esc keeps native /// only, and the General tab's a/d keys re-offer and detach. /// -[Collection("SampleAssemblies")] -public class PreIlcOfferTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class PreIlcOfferTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private const string DialogTitle = "Native AOT Sidecars Detected"; private Hex1bAppWorkloadAdapter? _workload; @@ -23,7 +25,7 @@ public class PreIlcOfferTests(SampleAssemblyFixture samples) : IDisposable private (Hex1bTerminal terminal, Hex1bApp app, CancellationToken ct) CreateDotsiderApp(string path) { - _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + _cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); _workload = new Hex1bAppWorkloadAdapter(); _terminal = Hex1bTerminal.CreateBuilder() .WithWorkload(_workload) @@ -43,12 +45,13 @@ public class PreIlcOfferTests(SampleAssemblyFixture samples) : IDisposable } /// The offer dialog appears (and only it, never the apphost dialog) for an attachable Native AOT binary. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task NativeAot_Attachable_ShowsOfferDialog() { - Assert.SkipWhen(samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); - var (terminal, app, ct) = CreateDotsiderApp(samples.NativeAotConsoleExe!); + var (terminal, app, ct) = CreateDotsiderApp(Samples.NativeAotConsoleExe!); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -57,18 +60,19 @@ public async Task NativeAot_Attachable_ShowsOfferDialog() .Build() .ApplyAsync(terminal, ct); - Assert.True(_state!.PreIlcDialogOpen); - Assert.False(_state.ApphostDialogOpen, "the AOT and apphost offers are mutually exclusive"); - Assert.Null(_state.Analyzer.PreIlcCompanions); + Assert.IsTrue(_state!.PreIlcDialogOpen); + Assert.IsFalse(_state.ApphostDialogOpen, "the AOT and apphost offers are mutually exclusive"); + Assert.IsNull(_state.Analyzer.PreIlcCompanions); _cts!.Cancel(); } /// An apphost exe opens the apphost dialog, never the pre-ILC sidecar dialog. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Apphost_ShowsApphostDialog_NotPreIlcDialog() { - var (terminal, app, ct) = CreateDotsiderApp(samples.HelloWorldExe); + var (terminal, app, ct) = CreateDotsiderApp(Samples.HelloWorldExe); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -77,19 +81,20 @@ public async Task Apphost_ShowsApphostDialog_NotPreIlcDialog() .Build() .ApplyAsync(terminal, ct); - Assert.True(_state!.ApphostDialogOpen); - Assert.False(_state.PreIlcDialogOpen); + Assert.IsTrue(_state!.ApphostDialogOpen); + Assert.IsFalse(_state.PreIlcDialogOpen); _cts!.Cancel(); } /// Enter attaches the companion set alongside the native analyzer, never replacing it. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Offer_Enter_AttachesAlongside() { - Assert.SkipWhen(samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); - var (terminal, app, ct) = CreateDotsiderApp(samples.NativeAotConsoleExe!); + var (terminal, app, ct) = CreateDotsiderApp(Samples.NativeAotConsoleExe!); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -100,21 +105,22 @@ public async Task Offer_Enter_AttachesAlongside() .Build() .ApplyAsync(terminal, ct); - Assert.False(_state!.PreIlcDialogOpen); - Assert.NotNull(_state.Analyzer.PreIlcCompanions); - Assert.True(_state.IsNativeAot, "attaching must not replace the native analyzer"); - Assert.Empty(_state.NavigationStack); + Assert.IsFalse(_state!.PreIlcDialogOpen); + Assert.IsNotNull(_state.Analyzer.PreIlcCompanions); + Assert.IsTrue(_state.IsNativeAot, "attaching must not replace the native analyzer"); + Assert.IsEmpty(_state.NavigationStack); _cts!.Cancel(); } /// Escape declines the offer, keeping the binary native-only. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Offer_Escape_KeepsNativeOnly() { - Assert.SkipWhen(samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); - var (terminal, app, ct) = CreateDotsiderApp(samples.NativeAotConsoleExe!); + var (terminal, app, ct) = CreateDotsiderApp(Samples.NativeAotConsoleExe!); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -125,20 +131,21 @@ public async Task Offer_Escape_KeepsNativeOnly() .Build() .ApplyAsync(terminal, ct); - Assert.False(_state!.PreIlcDialogOpen); - Assert.Null(_state.Analyzer.PreIlcCompanions); - Assert.True(_state.IsNativeAot); + Assert.IsFalse(_state!.PreIlcDialogOpen); + Assert.IsNull(_state.Analyzer.PreIlcCompanions); + Assert.IsTrue(_state.IsNativeAot); _cts!.Cancel(); } /// After declining, the General tab's a re-opens the offer and d detaches. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task General_A_ReoffersAnd_D_Detaches() { - Assert.SkipWhen(samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); - var (terminal, app, ct) = CreateDotsiderApp(samples.NativeAotConsoleExe!); + var (terminal, app, ct) = CreateDotsiderApp(Samples.NativeAotConsoleExe!); var runTask = app.RunAsync(ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -163,7 +170,7 @@ await auto.WaitUntilAsync(_ => _state!.Analyzer.PreIlcCompanions is not null, await auto.WaitUntilAsync(_ => _state!.Analyzer.PreIlcCompanions is null, description: "companions detached"); - Assert.Null(_state!.Analyzer.PreIlcCompanions); + Assert.IsNull(_state!.Analyzer.PreIlcCompanions); _cts!.Cancel(); } @@ -172,11 +179,12 @@ await auto.WaitUntilAsync(_ => _state!.Analyzer.PreIlcCompanions is null, /// An mstat-only discovery — a recognized build tree whose obj holds the mstat but no managed /// assembly — is found by the probe yet never opens the dialog (nothing attachable). /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task MstatOnly_NoAttachableCompanion_NoDialog() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); // Build a classic publish tree with the mstat in obj\...\native but NO managed dll — // the probe recognizes the tree and finds mstat-only, so HasAttachableCompanion is false. @@ -189,9 +197,9 @@ public async Task MstatOnly_NoAttachableCompanion_NoDialog() Directory.CreateDirectory(objNativeDir); try { - var exeCopy = Path.Combine(publishDir, Path.GetFileName(samples.NativeAotConsoleExe!)); - File.Copy(samples.NativeAotConsoleExe!, exeCopy); - File.Copy(samples.NativeAotConsoleMstat!, Path.Combine(objNativeDir, "NativeAotConsole.mstat")); + var exeCopy = Path.Combine(publishDir, Path.GetFileName(Samples.NativeAotConsoleExe!)); + File.Copy(Samples.NativeAotConsoleExe!, exeCopy); + File.Copy(Samples.NativeAotConsoleMstat!, Path.Combine(objNativeDir, "NativeAotConsole.mstat")); var (terminal, app, ct) = CreateDotsiderApp(exeCopy); var runTask = app.RunAsync(ct); @@ -202,9 +210,9 @@ public async Task MstatOnly_NoAttachableCompanion_NoDialog() .Build() .ApplyAsync(terminal, ct); - Assert.False(_state!.PreIlcDialogOpen); - Assert.False(_state.Analyzer.PreIlcSidecars?.HasAttachableCompanion ?? false); - Assert.NotNull(_state.Analyzer.PreIlcSidecars?.MstatPath); + Assert.IsFalse(_state!.PreIlcDialogOpen); + Assert.IsFalse(_state.Analyzer.PreIlcSidecars?.HasAttachableCompanion ?? false); + Assert.IsNotNull(_state.Analyzer.PreIlcSidecars?.MstatPath); _cts!.Cancel(); } diff --git a/tests/Dotsider.Tests/PreIlcRoutingTests.cs b/tests/Dotsider.Tests/PreIlcRoutingTests.cs index 7fca4855..69c41fd4 100644 --- a/tests/Dotsider.Tests/PreIlcRoutingTests.cs +++ b/tests/Dotsider.Tests/PreIlcRoutingTests.cs @@ -10,9 +10,11 @@ namespace Dotsider.Tests; /// the companion root, its managed types become visible, the correlation index builds, and detach /// restores native routing. /// -[Collection("SampleAssemblies")] -public class PreIlcRoutingTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class PreIlcRoutingTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private const string DialogTitle = "Native AOT Sidecars Detected"; private Hex1bAppWorkloadAdapter? _workload; @@ -20,10 +22,11 @@ public class PreIlcRoutingTests(SampleAssemblyFixture samples) : IDisposable private Hex1bApp? _hex1bApp; private DotsiderState? _state; private CancellationTokenSource? _cts; + private Task? _runTask; private (Hex1bTerminal terminal, Hex1bApp app, CancellationToken ct) CreateDotsiderApp(string path) { - _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + _cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); _workload = new Hex1bAppWorkloadAdapter(); _terminal = Hex1bTerminal.CreateBuilder() .WithWorkload(_workload) @@ -44,8 +47,8 @@ public class PreIlcRoutingTests(SampleAssemblyFixture samples) : IDisposable private async Task AttachAsync() { - var (terminal, app, ct) = CreateDotsiderApp(samples.NativeAotConsoleExe!); - _ = app.RunAsync(ct); + var (terminal, app, ct) = CreateDotsiderApp(Samples.NativeAotConsoleExe!); + _runTask = app.RunAsync(ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); await auto.WaitUntilAlternateScreenAsync(); @@ -57,13 +60,14 @@ await auto.WaitUntilAsync(_ => _state!.Analyzer.PreIlcCompanions is not null, } /// Before attaching, the metadata analyzer is the native analyzer itself. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Detached_MetadataAnalyzer_IsNativeAnalyzer() { - Assert.SkipWhen(samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); - var (terminal, app, ct) = CreateDotsiderApp(samples.NativeAotConsoleExe!); - _ = app.RunAsync(ct); + var (terminal, app, ct) = CreateDotsiderApp(Samples.NativeAotConsoleExe!); + _runTask = app.RunAsync(ct); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); await auto.WaitUntilAlternateScreenAsync(); @@ -71,59 +75,62 @@ public async Task Detached_MetadataAnalyzer_IsNativeAnalyzer() await auto.EscapeAsync(ct); await auto.WaitUntilAsync(s => !s.ContainsText(DialogTitle), description: "offer declined"); - Assert.Same(_state!.Analyzer, _state.MetadataAnalyzer); + Assert.AreSame(_state!.Analyzer, _state.MetadataAnalyzer); _cts!.Cancel(); } /// Attaching routes metadata to the managed companion, exposing its managed types. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Attached_MetadataAnalyzer_IsCompanionRootWithManagedTypes() { - Assert.SkipWhen(samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); await AttachAsync(); - Assert.NotSame(_state!.Analyzer, _state.MetadataAnalyzer); - Assert.True(_state.MetadataAnalyzer.HasMetadata); - Assert.Equal("NativeAotConsole", _state.MetadataAnalyzer.AssemblyName); - Assert.Contains(_state.MetadataAnalyzer.TypeDefs, t => t.Name == "Greeter"); + Assert.AreNotSame(_state!.Analyzer, _state.MetadataAnalyzer); + Assert.IsTrue(_state.MetadataAnalyzer.HasMetadata); + Assert.AreEqual("NativeAotConsole", _state.MetadataAnalyzer.AssemblyName); + Assert.Contains(t => t.Name == "Greeter", _state.MetadataAnalyzer.TypeDefs); // The binary stays native. - Assert.True(_state.IsNativeAot); + Assert.IsTrue(_state.IsNativeAot); _cts!.Cancel(); } /// The correlation index builds over the companion set after attach. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Attached_CorrelationIndex_Builds() { - Assert.SkipWhen(samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); var auto = await AttachAsync(); _state!.EnsureManagedNativeIndexAsync(); await auto.WaitUntilAsync(_ => _state!.PreIlcIndex is not null, description: "correlation index built"); - Assert.NotNull(_state.PreIlcIndex); - Assert.True(_state.PreIlcIndex!.Methods.Count > 0); + Assert.IsNotNull(_state.PreIlcIndex); + Assert.IsGreaterThan(0, _state.PreIlcIndex!.Methods.Count); _cts!.Cancel(); } /// Detaching restores native metadata routing and clears the index. - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Detach_RestoresNativeRouting() { - Assert.SkipWhen(samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); var auto = await AttachAsync(); _state!.DetachPreIlc(); await auto.WaitUntilAsync(_ => _state!.Analyzer.PreIlcCompanions is null, description: "companions detached"); - Assert.Same(_state.Analyzer, _state.MetadataAnalyzer); - Assert.Null(_state.PreIlcIndex); + Assert.AreSame(_state.Analyzer, _state.MetadataAnalyzer); + Assert.IsNull(_state.PreIlcIndex); _cts!.Cancel(); } @@ -133,6 +140,9 @@ public void Dispose() { GC.SuppressFinalize(this); _cts?.Cancel(); + try { _runTask?.Wait(TimeSpan.FromSeconds(5)); } + catch (AggregateException ex) when (ex.InnerExceptions.All(static e => e is OperationCanceledException)) { } + catch (OperationCanceledException) { } _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); diff --git a/tests/Dotsider.Tests/PreIlcSessionSocketTests.cs b/tests/Dotsider.Tests/PreIlcSessionSocketTests.cs index 4ba3ce97..e494b1c2 100644 --- a/tests/Dotsider.Tests/PreIlcSessionSocketTests.cs +++ b/tests/Dotsider.Tests/PreIlcSessionSocketTests.cs @@ -13,15 +13,18 @@ namespace Dotsider.Tests; /// TUI and diagnostics socket: a unique method resolved by name, the ambiguous-name error, and the /// managed-assembly rejection. /// -[Collection("SampleAssemblies")] -public class PreIlcSessionSocketTests(SampleAssemblyFixture samples) : IAsyncDisposable +[TestClass] +public class PreIlcSessionSocketTests : IAsyncDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _app; private DotsiderState? _state; private DotsiderDiagnosticsListener? _listener; private CancellationTokenSource? _appCts; + private Task? _appTask; private async Task StartTuiWithDiagnosticsAsync(string dllPath, CancellationToken ct) { @@ -46,13 +49,13 @@ private async Task StartTuiWithDiagnosticsAsync(string dllPath, Cancella { WorkloadAdapter = _workload, EnableInputCoalescing = false - }); + }); _listener = new DotsiderDiagnosticsListener(() => _state); - _listener.StartListening(); + _listener.StartListening(overridePid: TestSocketIds.NextPid()); _appCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - _ = _app.RunAsync(_appCts.Token); + _appTask = _app.RunAsync(_appCts.Token); await Task.Delay(100, ct); await TestHelpers.WaitUntilAsync( @@ -65,56 +68,59 @@ await TestHelpers.WaitUntilAsync( /// /// Verifies correlate-method resolves a unique method by name and returns its report. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task CorrelateMethod_ByName_ReturnsReport() { - Assert.SkipWhen(samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); - var ct = TestContext.Current.CancellationToken; - var socketPath = await StartTuiWithDiagnosticsAsync(samples.NativeAotConsoleExe!, ct); + var ct = CancellationToken.None; + var socketPath = await StartTuiWithDiagnosticsAsync(Samples.NativeAotConsoleExe!, ct); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "correlate-method", MethodOrAddress = "Greeter.Describe" }, ct); - Assert.True(response.Success, response.Error); + Assert.IsTrue(response.Success, response.Error); var data = (response.Data as JsonElement?)!.Value; - Assert.Contains("Greeter::Describe", data.GetProperty("method").GetString()); - Assert.False(string.IsNullOrEmpty(data.GetProperty("il").GetString())); + Assert.Contains("Greeter::Describe", data.GetProperty("method").GetString()!); + Assert.IsFalse(string.IsNullOrEmpty(data.GetProperty("il").GetString())); } /// /// Verifies an ambiguous name fails cleanly, listing the candidates in the error. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task CorrelateMethod_AmbiguousName_FailsWithCandidates() { - Assert.SkipWhen(samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); + TestSkip.When(Samples.NativeAotConsoleManagedDll is null, "pre-ILC companion was not produced"); - var ct = TestContext.Current.CancellationToken; - var socketPath = await StartTuiWithDiagnosticsAsync(samples.NativeAotConsoleExe!, ct); + var ct = CancellationToken.None; + var socketPath = await StartTuiWithDiagnosticsAsync(Samples.NativeAotConsoleExe!, ct); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "correlate-method", MethodOrAddress = "Greeter.Greet" }, ct); - Assert.False(response.Success); - Assert.Contains("ambiguous", response.Error); - Assert.Contains("Greeter::Greet", response.Error); + Assert.IsFalse(response.Success); + Assert.Contains("ambiguous", response.Error!); + Assert.Contains("Greeter::Greet", response.Error!); } /// /// Verifies correlate-method rejects a managed assembly with the Native AOT requirement. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task CorrelateMethod_Managed_Fails() { - var ct = TestContext.Current.CancellationToken; - var socketPath = await StartTuiWithDiagnosticsAsync(samples.RichLibraryDll, ct); + var ct = CancellationToken.None; + var socketPath = await StartTuiWithDiagnosticsAsync(Samples.RichLibraryDll, ct); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "correlate-method", MethodOrAddress = "Foo" }, ct); - Assert.False(response.Success); - Assert.Contains("Native AOT", response.Error); + Assert.IsFalse(response.Success); + Assert.Contains("Native AOT", response.Error!); } /// @@ -126,7 +132,13 @@ public async ValueTask DisposeAsync() _appCts?.Cancel(); if (_listener is not null) await _listener.DisposeAsync(); + if (_appTask is not null) + { + try { await _appTask; } + catch (OperationCanceledException) { } + } _state?.Dispose(); + _app?.Dispose(); if (_terminal is not null) await _terminal.DisposeAsync(); _appCts?.Dispose(); diff --git a/tests/Dotsider.Tests/PreIlcSidecarDetectorTests.cs b/tests/Dotsider.Tests/PreIlcSidecarDetectorTests.cs index f9f52d8d..d4fc44ed 100644 --- a/tests/Dotsider.Tests/PreIlcSidecarDetectorTests.cs +++ b/tests/Dotsider.Tests/PreIlcSidecarDetectorTests.cs @@ -7,9 +7,11 @@ namespace Dotsider.Tests; /// Tests for the pre-ILC sidecar detector: origin precedence, tree recognition, /// response-file parsing, reference categorization, and PDB status. /// -[Collection("SampleAssemblies")] -public class PreIlcSidecarDetectorTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class PreIlcSidecarDetectorTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private readonly List _tempFiles = []; private string NewTempDir() @@ -46,61 +48,65 @@ private static string CreateClassicTree(string root, string stem, out string obj } /// Verifies the classic publish tree yields the intermediate managed input. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_ClassicPublishTree_FindsBuildTreeLayoutDll() { var root = NewTempDir(); var exe = CreateClassicTree(root, "HelloWorld", out var objDir); - CopyInto(samples.HelloWorldDll, Path.Combine(objDir, "HelloWorld.dll")); + CopyInto(Samples.HelloWorldDll, Path.Combine(objDir, "HelloWorld.dll")); var result = PreIlcSidecarDetector.Find(exe); - Assert.NotNull(result); - Assert.True(result!.HasAttachableCompanion); - Assert.Equal(PreIlcAssemblyOrigin.BuildTreeLayout, result.Origin); - Assert.Equal(Path.Combine(objDir, "HelloWorld.dll"), result.ManagedAssemblyPath); + Assert.IsNotNull(result); + Assert.IsTrue(result!.HasAttachableCompanion); + Assert.AreEqual(PreIlcAssemblyOrigin.BuildTreeLayout, result.Origin); + Assert.AreEqual(Path.Combine(objDir, "HelloWorld.dll"), result.ManagedAssemblyPath); } /// Verifies the classic non-publish bin directory is recognized too. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_ClassicBinTree_FindsBuildTreeLayoutDll() { var root = NewTempDir(); var exe = CreateClassicTree(root, "HelloWorld", out var objDir, publish: false); - CopyInto(samples.HelloWorldDll, Path.Combine(objDir, "HelloWorld.dll")); + CopyInto(Samples.HelloWorldDll, Path.Combine(objDir, "HelloWorld.dll")); var result = PreIlcSidecarDetector.Find(exe); - Assert.NotNull(result); - Assert.Equal(PreIlcAssemblyOrigin.BuildTreeLayout, result!.Origin); + Assert.IsNotNull(result); + Assert.AreEqual(PreIlcAssemblyOrigin.BuildTreeLayout, result!.Origin); } /// Verifies the artifacts layout maps publish\proj\pivot to obj\proj\pivot by segment substitution. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_ArtifactsLayout_FindsBuildTreeLayoutDll() { var root = NewTempDir(); var exe = Path.Combine(root, "artifacts", "publish", "HelloWorld", "release_win-x64", "HelloWorld.exe"); WriteDummyBinary(exe); var objDir = Path.Combine(root, "artifacts", "obj", "HelloWorld", "release_win-x64"); - CopyInto(samples.HelloWorldDll, Path.Combine(objDir, "HelloWorld.dll")); + CopyInto(Samples.HelloWorldDll, Path.Combine(objDir, "HelloWorld.dll")); var result = PreIlcSidecarDetector.Find(exe); - Assert.NotNull(result); - Assert.Equal(PreIlcAssemblyOrigin.BuildTreeLayout, result!.Origin); - Assert.Equal(Path.Combine(objDir, "HelloWorld.dll"), result.ManagedAssemblyPath); + Assert.IsNotNull(result); + Assert.AreEqual(PreIlcAssemblyOrigin.BuildTreeLayout, result!.Origin); + Assert.AreEqual(Path.Combine(objDir, "HelloWorld.dll"), result.ManagedAssemblyPath); } /// Verifies the response file outranks the conventional obj location. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_RspAndObjBothPresent_RspWins() { var root = NewTempDir(); var exe = CreateClassicTree(root, "HelloWorld", out var objDir); - CopyInto(samples.HelloWorldDll, Path.Combine(objDir, "HelloWorld.dll")); + CopyInto(Samples.HelloWorldDll, Path.Combine(objDir, "HelloWorld.dll")); var altDir = Path.Combine(objDir, "alt"); - CopyInto(samples.HelloWorldDll, Path.Combine(altDir, "HelloWorld.dll")); + CopyInto(Samples.HelloWorldDll, Path.Combine(altDir, "HelloWorld.dll")); var nativeDir = Path.Combine(objDir, "native"); Directory.CreateDirectory(nativeDir); File.WriteAllLines(Path.Combine(nativeDir, "HelloWorld.ilc.rsp"), @@ -111,59 +117,62 @@ public void Find_RspAndObjBothPresent_RspWins() var result = PreIlcSidecarDetector.Find(exe); - Assert.NotNull(result); - Assert.Equal(PreIlcAssemblyOrigin.IlcResponseFile, result!.Origin); - Assert.Equal(Path.Combine(altDir, "HelloWorld.dll"), result.ManagedAssemblyPath); - Assert.Equal(Path.Combine(nativeDir, "HelloWorld.ilc.rsp"), result.IlcResponseFilePath); + Assert.IsNotNull(result); + Assert.AreEqual(PreIlcAssemblyOrigin.IlcResponseFile, result!.Origin); + Assert.AreEqual(Path.Combine(altDir, "HelloWorld.dll"), result.ManagedAssemblyPath); + Assert.AreEqual(Path.Combine(nativeDir, "HelloWorld.ilc.rsp"), result.IlcResponseFilePath); } /// Verifies quoted and absolute root-input tokens both resolve. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_RspRootQuotedAndAbsolute_Resolves() { var root = NewTempDir(); var exe = CreateClassicTree(root, "HelloWorld", out var objDir); var dllPath = Path.Combine(objDir, "HelloWorld.dll"); - CopyInto(samples.HelloWorldDll, dllPath); + CopyInto(Samples.HelloWorldDll, dllPath); var nativeDir = Path.Combine(objDir, "native"); Directory.CreateDirectory(nativeDir); File.WriteAllLines(Path.Combine(nativeDir, "HelloWorld.ilc.rsp"), [$"\"{dllPath}\""]); var result = PreIlcSidecarDetector.Find(exe); - Assert.NotNull(result); - Assert.Equal(PreIlcAssemblyOrigin.IlcResponseFile, result!.Origin); - Assert.Equal(dllPath, result.ManagedAssemblyPath); + Assert.IsNotNull(result); + Assert.AreEqual(PreIlcAssemblyOrigin.IlcResponseFile, result!.Origin); + Assert.AreEqual(dllPath, result.ManagedAssemblyPath); } /// Verifies the separated -r value form and inline forms are all collected. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_RspSeparatedReferenceForm_CollectsReference() { var root = NewTempDir(); var exe = CreateClassicTree(root, "HelloWorld", out var objDir); var dllPath = Path.Combine(objDir, "HelloWorld.dll"); - CopyInto(samples.HelloWorldDll, dllPath); + CopyInto(Samples.HelloWorldDll, dllPath); var libPath = Path.Combine(root, "libproj", "bin", "Release", "net10.0", "RichLibrary.dll"); - CopyInto(samples.RichLibraryDll, libPath); + CopyInto(Samples.RichLibraryDll, libPath); var nativeDir = Path.Combine(objDir, "native"); Directory.CreateDirectory(nativeDir); File.WriteAllLines(Path.Combine(nativeDir, "HelloWorld.ilc.rsp"), [dllPath, "-r", libPath]); var result = PreIlcSidecarDetector.Find(exe); - Assert.NotNull(result); + Assert.IsNotNull(result); Assert.Contains(libPath, result!.LocalReferencePaths); } /// Verifies @-inclusion expands nested response files and a cycle is survived with a note. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_RspIncludesAndCycle_ExpandsAndSurvives() { var root = NewTempDir(); var exe = CreateClassicTree(root, "HelloWorld", out var objDir); var dllPath = Path.Combine(objDir, "HelloWorld.dll"); - CopyInto(samples.HelloWorldDll, dllPath); + CopyInto(Samples.HelloWorldDll, dllPath); var nativeDir = Path.Combine(objDir, "native"); Directory.CreateDirectory(nativeDir); var rsp = Path.Combine(nativeDir, "HelloWorld.ilc.rsp"); @@ -173,23 +182,24 @@ public void Find_RspIncludesAndCycle_ExpandsAndSurvives() var result = PreIlcSidecarDetector.Find(exe); - Assert.NotNull(result); - Assert.Equal(PreIlcAssemblyOrigin.IlcResponseFile, result!.Origin); - Assert.Equal(dllPath, result.ManagedAssemblyPath); - Assert.Contains("cycle", result.Details, StringComparison.OrdinalIgnoreCase); + Assert.IsNotNull(result); + Assert.AreEqual(PreIlcAssemblyOrigin.IlcResponseFile, result!.Origin); + Assert.AreEqual(dllPath, result.ManagedAssemblyPath); + Assert.Contains("cycle", result.Details!, StringComparison.OrdinalIgnoreCase); } /// /// Verifies references are categorized: package store summarized, local evidence listed, /// unclassifiable counted, missing recorded as unresolved. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_ReferenceCategorization_SeparatesPackageLocalOtherUnresolved() { var root = NewTempDir(); var exe = CreateClassicTree(root, "HelloWorld", out var objDir); var dllPath = Path.Combine(objDir, "HelloWorld.dll"); - CopyInto(samples.HelloWorldDll, dllPath); + CopyInto(Samples.HelloWorldDll, dllPath); var packageRef = Path.Combine(root, "custom-cache", "microsoft.netcore.app.runtime.nativeaot.win-x64", "10.0.9", "lib", "System.Runtime.dll"); @@ -198,10 +208,10 @@ public void Find_ReferenceCategorization_SeparatesPackageLocalOtherUnresolved() WriteDummyBinary(otherPackageRef); var localRef = Path.Combine(root, "libproj", "bin", "Release", "net10.0", "RichLibrary.dll"); - CopyInto(samples.RichLibraryDll, localRef); + CopyInto(Samples.RichLibraryDll, localRef); var unclassifiable = Path.Combine(root, "misc", "Elsewhere.dll"); - CopyInto(samples.RichLibraryDll, unclassifiable); + CopyInto(Samples.RichLibraryDll, unclassifiable); var missing = Path.Combine(root, "gone", "Missing.dll"); @@ -219,21 +229,22 @@ public void Find_ReferenceCategorization_SeparatesPackageLocalOtherUnresolved() var result = PreIlcSidecarDetector.Find(exe); - Assert.NotNull(result); - Assert.Equal(2, result!.PackageReferenceCount); - Assert.Equal([localRef], result.LocalReferencePaths); - Assert.Equal(1, result.OtherReferenceCount); - Assert.Contains("Elsewhere.dll", result.Details, StringComparison.Ordinal); - Assert.Equal([missing], result.UnresolvedReferencePaths); + Assert.IsNotNull(result); + Assert.AreEqual(2, result!.PackageReferenceCount); + Assert.AreSequenceEqual([localRef], result.LocalReferencePaths); + Assert.AreEqual(1, result.OtherReferenceCount); + Assert.Contains("Elsewhere.dll", result.Details!, StringComparison.Ordinal); + Assert.AreSequenceEqual([missing], result.UnresolvedReferencePaths); } /// Verifies a missing response-file root falls through to the obj layout with a note. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_RspRootMissing_FallsBackToObjLayout() { var root = NewTempDir(); var exe = CreateClassicTree(root, "HelloWorld", out var objDir); - CopyInto(samples.HelloWorldDll, Path.Combine(objDir, "HelloWorld.dll")); + CopyInto(Samples.HelloWorldDll, Path.Combine(objDir, "HelloWorld.dll")); var nativeDir = Path.Combine(objDir, "native"); Directory.CreateDirectory(nativeDir); File.WriteAllLines(Path.Combine(nativeDir, "HelloWorld.ilc.rsp"), @@ -241,43 +252,46 @@ public void Find_RspRootMissing_FallsBackToObjLayout() var result = PreIlcSidecarDetector.Find(exe); - Assert.NotNull(result); - Assert.Equal(PreIlcAssemblyOrigin.BuildTreeLayout, result!.Origin); - Assert.Contains("fell back", result.Details, StringComparison.Ordinal); + Assert.IsNotNull(result); + Assert.AreEqual(PreIlcAssemblyOrigin.BuildTreeLayout, result!.Origin); + Assert.Contains("fell back", result.Details!, StringComparison.Ordinal); } /// Verifies a lone sibling dll is offered with sibling provenance. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_SiblingOnly_FindsSiblingAssembly() { var dir = NewTempDir(); var exe = Path.Combine(dir, "HelloWorld.exe"); WriteDummyBinary(exe); - CopyInto(samples.HelloWorldDll, Path.Combine(dir, "HelloWorld.dll")); + CopyInto(Samples.HelloWorldDll, Path.Combine(dir, "HelloWorld.dll")); var result = PreIlcSidecarDetector.Find(exe); - Assert.NotNull(result); - Assert.Equal(PreIlcAssemblyOrigin.SiblingAssembly, result!.Origin); + Assert.IsNotNull(result); + Assert.AreEqual(PreIlcAssemblyOrigin.SiblingAssembly, result!.Origin); } /// Verifies extensionless (Linux) binaries keep their full name as the stem. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_ExtensionlessBinary_UsesFullNameStem() { var dir = NewTempDir(); var exe = Path.Combine(dir, "HelloWorld"); WriteDummyBinary(exe); - CopyInto(samples.HelloWorldDll, Path.Combine(dir, "HelloWorld.dll")); + CopyInto(Samples.HelloWorldDll, Path.Combine(dir, "HelloWorld.dll")); var result = PreIlcSidecarDetector.Find(exe); - Assert.NotNull(result); - Assert.True(result!.HasAttachableCompanion); + Assert.IsNotNull(result); + Assert.IsTrue(result!.HasAttachableCompanion); } /// Verifies native AOT library extensions (.so, .dylib) are stripped for the stem. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_LibraryExtensions_StripForStem() { foreach (var ext in new[] { ".so", ".dylib" }) @@ -285,17 +299,18 @@ public void Find_LibraryExtensions_StripForStem() var dir = NewTempDir(); var binary = Path.Combine(dir, "RichLibrary" + ext); WriteDummyBinary(binary); - CopyInto(samples.RichLibraryDll, Path.Combine(dir, "RichLibrary.dll")); + CopyInto(Samples.RichLibraryDll, Path.Combine(dir, "RichLibrary.dll")); var result = PreIlcSidecarDetector.Find(binary); - Assert.NotNull(result); - Assert.Equal(PreIlcAssemblyOrigin.SiblingAssembly, result!.Origin); + Assert.IsNotNull(result); + Assert.AreEqual(PreIlcAssemblyOrigin.SiblingAssembly, result!.Origin); } } /// Verifies a Windows native AOT library never offers itself (sibling == binary path). - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_NativeLibrarySelfCollision_ReturnsNull() { var dir = NewTempDir(); @@ -304,42 +319,45 @@ public void Find_NativeLibrarySelfCollision_ReturnsNull() var result = PreIlcSidecarDetector.Find(binary); - Assert.Null(result); + Assert.IsNull(result); } /// Verifies uppercase segments and forward slashes are recognized. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_UppercaseSegmentsAndForwardSlashes_Recognized() { var root = NewTempDir(); var exe = CreateClassicTree(root, "HelloWorld", out var objDir); - CopyInto(samples.HelloWorldDll, Path.Combine(objDir, "HelloWorld.dll")); + CopyInto(Samples.HelloWorldDll, Path.Combine(objDir, "HelloWorld.dll")); var mangled = exe.Replace('\\', '/') .Replace("/bin/", "/BIN/", StringComparison.Ordinal) .Replace("/publish/", "/PUBLISH/", StringComparison.Ordinal); var result = PreIlcSidecarDetector.Find(mangled); - Assert.NotNull(result); - Assert.Equal(PreIlcAssemblyOrigin.BuildTreeLayout, result!.Origin); + Assert.IsNotNull(result); + Assert.AreEqual(PreIlcAssemblyOrigin.BuildTreeLayout, result!.Origin); } /// Verifies a sibling whose assembly name does not match the stem is rejected. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_SiblingNameMismatch_ReturnsNull() { var dir = NewTempDir(); var exe = Path.Combine(dir, "HelloWorld.exe"); WriteDummyBinary(exe); - CopyInto(samples.RichLibraryDll, Path.Combine(dir, "HelloWorld.dll")); + CopyInto(Samples.RichLibraryDll, Path.Combine(dir, "HelloWorld.dll")); var result = PreIlcSidecarDetector.Find(exe); - Assert.Null(result); + Assert.IsNull(result); } /// Verifies a metadata-less sibling file is rejected. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_SiblingWithoutMetadata_ReturnsNull() { var dir = NewTempDir(); @@ -349,81 +367,86 @@ public void Find_SiblingWithoutMetadata_ReturnsNull() var result = PreIlcSidecarDetector.Find(exe); - Assert.Null(result); + Assert.IsNull(result); } /// Verifies a matching sidecar PDB reports Matched. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_MatchingPdb_ReportsMatched() { - var sourcePdb = Path.ChangeExtension(samples.HelloWorldDll, ".pdb"); - Assert.SkipWhen(!File.Exists(sourcePdb), "HelloWorld.pdb was not produced"); + var sourcePdb = Path.ChangeExtension(Samples.HelloWorldDll, ".pdb"); + TestSkip.When(!File.Exists(sourcePdb), "HelloWorld.pdb was not produced"); var dir = NewTempDir(); var exe = Path.Combine(dir, "HelloWorld.exe"); WriteDummyBinary(exe); - CopyInto(samples.HelloWorldDll, Path.Combine(dir, "HelloWorld.dll")); + CopyInto(Samples.HelloWorldDll, Path.Combine(dir, "HelloWorld.dll")); CopyInto(sourcePdb, Path.Combine(dir, "HelloWorld.pdb")); var result = PreIlcSidecarDetector.Find(exe); - Assert.NotNull(result); - Assert.Equal(PreIlcPdbStatus.Matched, result!.PdbStatus); - Assert.Equal(Path.Combine(dir, "HelloWorld.pdb"), result.ManagedPdbPath); + Assert.IsNotNull(result); + Assert.AreEqual(PreIlcPdbStatus.Matched, result!.PdbStatus); + Assert.AreEqual(Path.Combine(dir, "HelloWorld.pdb"), result.ManagedPdbPath); } /// Verifies a foreign PDB reports Mismatched but the dll is still offered. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_MismatchedPdb_OffersDllWithoutPdb() { - var foreignPdb = Path.ChangeExtension(samples.RichLibraryDll, ".pdb"); - Assert.SkipWhen(!File.Exists(foreignPdb), "RichLibrary.pdb was not produced"); + var foreignPdb = Path.ChangeExtension(Samples.RichLibraryDll, ".pdb"); + TestSkip.When(!File.Exists(foreignPdb), "RichLibrary.pdb was not produced"); var dir = NewTempDir(); var exe = Path.Combine(dir, "HelloWorld.exe"); WriteDummyBinary(exe); - CopyInto(samples.HelloWorldDll, Path.Combine(dir, "HelloWorld.dll")); + CopyInto(Samples.HelloWorldDll, Path.Combine(dir, "HelloWorld.dll")); CopyInto(foreignPdb, Path.Combine(dir, "HelloWorld.pdb")); var result = PreIlcSidecarDetector.Find(exe); - Assert.NotNull(result); - Assert.True(result!.HasAttachableCompanion); - Assert.Equal(PreIlcPdbStatus.Mismatched, result.PdbStatus); + Assert.IsNotNull(result); + Assert.IsTrue(result!.HasAttachableCompanion); + Assert.AreEqual(PreIlcPdbStatus.Mismatched, result.PdbStatus); } /// Verifies an assembly with an embedded portable PDB reports Embedded, not Missing. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_EmbeddedPdb_ReportsEmbedded() { var dir = NewTempDir(); var exe = Path.Combine(dir, "EmbeddedSourceLib.exe"); WriteDummyBinary(exe); - CopyInto(samples.EmbeddedSourceLibDll, Path.Combine(dir, "EmbeddedSourceLib.dll")); + CopyInto(Samples.EmbeddedSourceLibDll, Path.Combine(dir, "EmbeddedSourceLib.dll")); var result = PreIlcSidecarDetector.Find(exe); - Assert.NotNull(result); - Assert.Equal(PreIlcPdbStatus.Embedded, result!.PdbStatus); + Assert.IsNotNull(result); + Assert.AreEqual(PreIlcPdbStatus.Embedded, result!.PdbStatus); } /// Verifies an absent PDB reports Missing. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_AbsentPdb_ReportsMissing() { var dir = NewTempDir(); var exe = Path.Combine(dir, "HelloWorld.exe"); WriteDummyBinary(exe); - CopyInto(samples.HelloWorldDll, Path.Combine(dir, "HelloWorld.dll")); + CopyInto(Samples.HelloWorldDll, Path.Combine(dir, "HelloWorld.dll")); var result = PreIlcSidecarDetector.Find(exe); - Assert.NotNull(result); - Assert.Equal(PreIlcPdbStatus.Missing, result!.PdbStatus); + Assert.IsNotNull(result); + Assert.AreEqual(PreIlcPdbStatus.Missing, result!.PdbStatus); } /// Verifies a binary outside any recognizable tree with no siblings yields null. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_OutsideAnyTree_ReturnsNull() { var dir = NewTempDir(); @@ -432,11 +455,12 @@ public void Find_OutsideAnyTree_ReturnsNull() var result = PreIlcSidecarDetector.Find(exe); - Assert.Null(result); + Assert.IsNull(result); } /// Verifies mstat/DGML-only discoveries produce a result that is not attachable. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_MstatOnlyInObjNative_NotAttachable() { var root = NewTempDir(); @@ -448,16 +472,17 @@ public void Find_MstatOnlyInObjNative_NotAttachable() var result = PreIlcSidecarDetector.Find(exe); - Assert.NotNull(result); - Assert.False(result!.HasAttachableCompanion); - Assert.Equal(PreIlcAssemblyOrigin.None, result.Origin); - Assert.Equal(Path.Combine(nativeDir, "HelloWorld.mstat"), result.MstatPath); - Assert.Equal(Path.Combine(nativeDir, "HelloWorld.codegen.dgml.xml"), result.CodegenDgmlPath); - Assert.Null(result.ScanDgmlPath); + Assert.IsNotNull(result); + Assert.IsFalse(result!.HasAttachableCompanion); + Assert.AreEqual(PreIlcAssemblyOrigin.None, result.Origin); + Assert.AreEqual(Path.Combine(nativeDir, "HelloWorld.mstat"), result.MstatPath); + Assert.AreEqual(Path.Combine(nativeDir, "HelloWorld.codegen.dgml.xml"), result.CodegenDgmlPath); + Assert.IsNull(result.ScanDgmlPath); } /// Verifies a managed input newer than the binary is noted but never blocking. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_StaleManagedInput_NotesStaleness() { var dir = NewTempDir(); @@ -465,75 +490,79 @@ public void Find_StaleManagedInput_NotesStaleness() WriteDummyBinary(exe); File.SetLastWriteTimeUtc(exe, DateTime.UtcNow.AddHours(-2)); var dll = Path.Combine(dir, "HelloWorld.dll"); - CopyInto(samples.HelloWorldDll, dll); + CopyInto(Samples.HelloWorldDll, dll); File.SetLastWriteTimeUtc(dll, DateTime.UtcNow); var result = PreIlcSidecarDetector.Find(exe); - Assert.NotNull(result); - Assert.True(result!.HasAttachableCompanion); - Assert.Contains("newer", result.Details, StringComparison.Ordinal); + Assert.IsNotNull(result); + Assert.IsTrue(result!.HasAttachableCompanion); + Assert.Contains("newer", result.Details!, StringComparison.Ordinal); } /// Verifies the real fixture publish tree: rsp origin, matched PDB, and obj mstat/DGML. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_FixturePublishTree_FindsRspOriginWithMatchedPdb() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var result = PreIlcSidecarDetector.Find(samples.NativeAotConsoleExe!); + var result = PreIlcSidecarDetector.Find(Samples.NativeAotConsoleExe!); - Assert.NotNull(result); - Assert.True(result!.HasAttachableCompanion); - Assert.Equal(PreIlcAssemblyOrigin.IlcResponseFile, result.Origin); + Assert.IsNotNull(result); + Assert.IsTrue(result!.HasAttachableCompanion); + Assert.AreEqual(PreIlcAssemblyOrigin.IlcResponseFile, result.Origin); Assert.EndsWith("NativeAotConsole.dll", result.ManagedAssemblyPath!, StringComparison.OrdinalIgnoreCase); - Assert.True(File.Exists(result.ManagedAssemblyPath)); - Assert.Equal(PreIlcPdbStatus.Matched, result.PdbStatus); - Assert.NotNull(result.MstatPath); - Assert.NotNull(result.CodegenDgmlPath); - Assert.NotNull(result.IlcResponseFilePath); - Assert.True(result.PackageReferenceCount > 0); - Assert.Empty(result.LocalReferencePaths); - Assert.Empty(result.UnresolvedReferencePaths); + Assert.IsTrue(File.Exists(result.ManagedAssemblyPath)); + Assert.AreEqual(PreIlcPdbStatus.Matched, result.PdbStatus); + Assert.IsNotNull(result.MstatPath); + Assert.IsNotNull(result.CodegenDgmlPath); + Assert.IsNotNull(result.IlcResponseFilePath); + Assert.IsGreaterThan(0, result.PackageReferenceCount); + Assert.IsEmpty(result.LocalReferencePaths); + Assert.IsEmpty(result.UnresolvedReferencePaths); } /// Verifies the real artifacts-layout publish maps publish→obj with obj-only sidecars. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_ArtifactsFixture_FindsObjSidecarsWithNoSiblings() { - Assert.SkipWhen(samples.NativeAotArtifactsExe is null, "artifacts NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotArtifactsExe is null, "artifacts NativeAOT sample was not built"); - var result = PreIlcSidecarDetector.Find(samples.NativeAotArtifactsExe!); + var result = PreIlcSidecarDetector.Find(Samples.NativeAotArtifactsExe!); - Assert.NotNull(result); - Assert.True(result!.HasAttachableCompanion); + Assert.IsNotNull(result); + Assert.IsTrue(result!.HasAttachableCompanion); Assert.Contains(Path.Combine("artifacts", "obj"), result.ManagedAssemblyPath!, StringComparison.OrdinalIgnoreCase); - Assert.NotNull(result.MstatPath); - Assert.NotNull(result.CodegenDgmlPath); + Assert.IsNotNull(result.MstatPath); + Assert.IsNotNull(result.CodegenDgmlPath); - var exeDir = Path.GetDirectoryName(samples.NativeAotArtifactsExe!)!; - Assert.False(File.Exists(Path.Combine(exeDir, "NativeAotArtifactsConsole.mstat"))); + var exeDir = Path.GetDirectoryName(Samples.NativeAotArtifactsExe!)!; + Assert.IsFalse(File.Exists(Path.Combine(exeDir, "NativeAotArtifactsConsole.mstat"))); } /// Verifies the real Native AOT library publish finds its companion via tree, not sibling. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Find_LibraryFixture_FindsCompanionViaTreeNotSibling() { - Assert.SkipWhen(samples.NativeAotLibraryBinary is null, "NativeAOT library sample was not built"); + TestSkip.When(Samples.NativeAotLibraryBinary is null, "NativeAOT library sample was not built"); - var result = PreIlcSidecarDetector.Find(samples.NativeAotLibraryBinary!); + var result = PreIlcSidecarDetector.Find(Samples.NativeAotLibraryBinary!); - Assert.NotNull(result); - Assert.True(result!.HasAttachableCompanion); - Assert.NotEqual(PreIlcAssemblyOrigin.SiblingAssembly, result.Origin); - Assert.NotEqual( - Path.GetFullPath(samples.NativeAotLibraryBinary!), + Assert.IsNotNull(result); + Assert.IsTrue(result!.HasAttachableCompanion); + Assert.AreNotEqual(PreIlcAssemblyOrigin.SiblingAssembly, result.Origin); + Assert.AreNotEqual( + Path.GetFullPath(Samples.NativeAotLibraryBinary!), Path.GetFullPath(result.ManagedAssemblyPath!)); } /// Disposes test resources created during the run. public void Dispose() { + GC.SuppressFinalize(this); foreach (var path in _tempFiles) { try @@ -543,6 +572,5 @@ public void Dispose() } catch { /* best effort */ } } - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/PreferredRuntimePackTests.cs b/tests/Dotsider.Tests/PreferredRuntimePackTests.cs index 98d9662f..fc239992 100644 --- a/tests/Dotsider.Tests/PreferredRuntimePackTests.cs +++ b/tests/Dotsider.Tests/PreferredRuntimePackTests.cs @@ -6,32 +6,37 @@ namespace Dotsider.Tests; /// Tests for detection /// across different assembly types. /// -[Collection("SampleAssemblies")] -public sealed class PreferredRuntimePackTests(SampleAssemblyFixture samples) +[TestClass] +public sealed class PreferredRuntimePackTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// Verifies that a regular library detects NETCore.App as its runtime pack. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DetectRuntimePack_RichLibrary_ReturnsNETCoreApp() { - using var analyzer = new AssemblyAnalyzer(samples.RichLibraryDll); - Assert.Equal("Microsoft.NETCore.App", analyzer.PreferredRuntimePack); + using var analyzer = new AssemblyAnalyzer(Samples.RichLibraryDll); + Assert.AreEqual("Microsoft.NETCore.App", analyzer.PreferredRuntimePack); } /// Verifies that a minimal API project detects AspNetCore.App as its runtime pack. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DetectRuntimePack_MinimalApi_ReturnsAspNetCoreApp() { - using var analyzer = new AssemblyAnalyzer(samples.MinimalApiDll); - Assert.Equal("Microsoft.AspNetCore.App", analyzer.PreferredRuntimePack); + using var analyzer = new AssemblyAnalyzer(Samples.MinimalApiDll); + Assert.AreEqual("Microsoft.AspNetCore.App", analyzer.PreferredRuntimePack); } /// Verifies that a NativeAOT exe with no metadata falls back to NETCore.App. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DetectRuntimePack_NoMetadata_ReturnsNETCoreApp() { // NativeAOT exe has no metadata — should fall back to NETCore.App - Assert.NotNull(samples.NativeAotConsoleExe); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); - Assert.Equal("Microsoft.NETCore.App", analyzer.PreferredRuntimePack); + Assert.IsNotNull(Samples.NativeAotConsoleExe); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); + Assert.AreEqual("Microsoft.NETCore.App", analyzer.PreferredRuntimePack); } } diff --git a/tests/Dotsider.Tests/ProtocolVersionTests.cs b/tests/Dotsider.Tests/ProtocolVersionTests.cs index f72aea6c..b7281f51 100644 --- a/tests/Dotsider.Tests/ProtocolVersionTests.cs +++ b/tests/Dotsider.Tests/ProtocolVersionTests.cs @@ -11,14 +11,18 @@ namespace Dotsider.Tests; /// Tests that protocol versioning works correctly on both server and client sides. /// Uses the full headless TUI stack with real assemblies. /// -[Collection("SampleAssemblies")] -public class ProtocolVersionTests(SampleAssemblyFixture samples) : IAsyncDisposable +[TestClass] +public class ProtocolVersionTests : IAsyncDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _app; private DotsiderState? _state; private DotsiderDiagnosticsListener? _listener; + private CancellationTokenSource? _appCts; + private Task? _appTask; private async Task StartTuiWithDiagnosticsAsync(CancellationToken ct) { @@ -34,7 +38,7 @@ private async Task StartTuiWithDiagnosticsAsync(CancellationToken ct) _app = new Hex1bApp( ctx => { - _state ??= new DotsiderState(_app!, samples.HelloWorldDll, pendingMutations); + _state ??= new DotsiderState(_app!, Samples.HelloWorldDll, pendingMutations); var dotsiderApp = new DotsiderApp(_state); return Task.FromResult(dotsiderApp.Build(ctx)); }, @@ -42,12 +46,13 @@ private async Task StartTuiWithDiagnosticsAsync(CancellationToken ct) { WorkloadAdapter = _workload, EnableInputCoalescing = false - }); + }); _listener = new DotsiderDiagnosticsListener(() => _state); - _listener.StartListening(overridePid: Random.Shared.Next(100_000, 999_999)); + _listener.StartListening(overridePid: TestSocketIds.NextPid()); - _ = _app.RunAsync(ct); + _appCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _appTask = _app.RunAsync(_appCts.Token); await Task.Delay(100, ct); await TestHelpers.WaitUntilAsync( @@ -63,34 +68,43 @@ await TestHelpers.WaitUntilAsync( public async ValueTask DisposeAsync() { GC.SuppressFinalize(this); + _appCts?.Cancel(); if (_listener is not null) await _listener.DisposeAsync(); + if (_appTask is not null) + { + try { await _appTask; } + catch (OperationCanceledException) { } + } _state?.Dispose(); _app?.Dispose(); if (_terminal is not null) await _terminal.DisposeAsync(); + _appCts?.Dispose(); } /// /// Verifies correct version succeeds. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task CorrectVersion_Succeeds() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var socketPath = await StartTuiWithDiagnosticsAsync(ct); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "assembly-info" }, ct); - Assert.True(response.Success); + Assert.IsTrue(response.Success); } /// /// Verifies missing version is rejected. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task MissingVersion_IsRejected() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var socketPath = await StartTuiWithDiagnosticsAsync(ct); // Send raw JSON without "v" field — [JsonRequired] throws JsonException @@ -98,36 +112,38 @@ public async Task MissingVersion_IsRejected() """{"method":"assembly-info"}""", ct); var response = JsonSerializer.Deserialize(rawResponse, DotsiderJsonOptions.Default); - Assert.NotNull(response); - Assert.False(response.Success); + Assert.IsNotNull(response); + Assert.IsFalse(response.Success); Assert.Contains("JSON", response.Error!, StringComparison.OrdinalIgnoreCase); } /// /// Verifies wrong version is rejected. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task WrongVersion_IsRejected() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var socketPath = await StartTuiWithDiagnosticsAsync(ct); var rawResponse = await DotsiderClient.SendRawAsync(socketPath, """{"v":99,"method":"assembly-info"}""", ct); var response = JsonSerializer.Deserialize(rawResponse, DotsiderJsonOptions.Default); - Assert.NotNull(response); - Assert.False(response.Success); + Assert.IsNotNull(response); + Assert.IsFalse(response.Success); Assert.Contains("version mismatch", response.Error!, StringComparison.OrdinalIgnoreCase); } /// /// Verifies response contains version. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Response_ContainsVersion() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var socketPath = await StartTuiWithDiagnosticsAsync(ct); var rawResponse = await DotsiderClient.SendRawAsync(socketPath, @@ -135,24 +151,25 @@ public async Task Response_ContainsVersion() DotsiderJsonOptions.Default), ct); var doc = JsonDocument.Parse(rawResponse); - Assert.True(doc.RootElement.TryGetProperty("v", out var v)); - Assert.Equal(1, v.GetInt32()); + Assert.IsTrue(doc.RootElement.TryGetProperty("v", out var v)); + Assert.AreEqual(1, v.GetInt32()); } /// /// Verifies pre routing errors contain version. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PreRoutingErrors_ContainVersion() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var socketPath = await StartTuiWithDiagnosticsAsync(ct); // Version mismatch error carries "v":1 var rawResponse = await DotsiderClient.SendRawAsync(socketPath, """{"v":99,"method":"assembly-info"}""", ct); var doc = JsonDocument.Parse(rawResponse); - Assert.Equal(1, doc.RootElement.GetProperty("v").GetInt32()); + Assert.AreEqual(1, doc.RootElement.GetProperty("v").GetInt32()); // Peer rejection error carries "v":1 _listener!.ForceRejectPeers = true; @@ -160,14 +177,15 @@ public async Task PreRoutingErrors_ContainVersion() JsonSerializer.Serialize(new DotsiderRequest { Method = "assembly-info" }, DotsiderJsonOptions.Default), ct); doc = JsonDocument.Parse(rawResponse); - Assert.Equal(1, doc.RootElement.GetProperty("v").GetInt32()); + Assert.AreEqual(1, doc.RootElement.GetProperty("v").GetInt32()); _listener.ForceRejectPeers = false; } /// /// Verifies dotsider client rejects old server response. /// - [Fact(Timeout = 10_000)] + [TestMethod] + [Timeout(10_000, CooperativeCancellation = true)] public async Task DotsiderClient_RejectsOldServerResponse() { var socketPath = GetUniqueSocketPath(); @@ -177,16 +195,17 @@ public async Task DotsiderClient_RejectsOldServerResponse() var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "assembly-info" }, - TestContext.Current.CancellationToken); + CancellationToken.None); - Assert.False(response.Success); + Assert.IsFalse(response.Success); Assert.Contains("server response", response.Error!, StringComparison.OrdinalIgnoreCase); } /// /// Verifies dotsider client rejects wrong server version. /// - [Fact(Timeout = 10_000)] + [TestMethod] + [Timeout(10_000, CooperativeCancellation = true)] public async Task DotsiderClient_RejectsWrongServerVersion() { var socketPath = GetUniqueSocketPath(); @@ -196,9 +215,9 @@ public async Task DotsiderClient_RejectsWrongServerVersion() var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "assembly-info" }, - TestContext.Current.CancellationToken); + CancellationToken.None); - Assert.False(response.Success); + Assert.IsFalse(response.Success); Assert.Contains("version mismatch", response.Error!, StringComparison.OrdinalIgnoreCase); } @@ -208,6 +227,6 @@ private static string GetUniqueSocketPath() Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".dotsider", "sockets"); Directory.CreateDirectory(dir); - return Path.Combine(dir, $"test-{Random.Shared.Next(100_000, 999_999)}.dotsider.socket"); + return Path.Combine(dir, $"test-{TestSocketIds.NextPid()}.dotsider.socket"); } } diff --git a/tests/Dotsider.Tests/ReadyToRunAnalyzerTests.cs b/tests/Dotsider.Tests/ReadyToRunAnalyzerTests.cs index 053b00fa..13e76f05 100644 --- a/tests/Dotsider.Tests/ReadyToRunAnalyzerTests.cs +++ b/tests/Dotsider.Tests/ReadyToRunAnalyzerTests.cs @@ -11,109 +11,114 @@ namespace Dotsider.Tests; /// plain managed library is untouched. The version-acceptance rule is validated on the real image's /// actual major version. /// -[Collection("SampleAssemblies")] -public class ReadyToRunAnalyzerTests(SampleAssemblyFixture samples) +[TestClass] +public class ReadyToRunAnalyzerTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private const string SkipReason = "ReadyToRun crossgen2 publish did not run on this leg."; /// A non-composite R2R publish classifies as ReadyToRun, keeps metadata, and images its own code. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NonComposite_ClassifiesAsReadyToRun() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleDll!); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleDll!); - Assert.Equal(BinaryKind.ReadyToRun, analyzer.BinaryKind); - Assert.True(analyzer.IsReadyToRun); - Assert.True(analyzer.HasManagedMetadata, "an R2R image keeps its ECMA-335 metadata"); - Assert.True(analyzer.HasEmbeddedNativeCode); + Assert.AreEqual(BinaryKind.ReadyToRun, analyzer.BinaryKind); + Assert.IsTrue(analyzer.IsReadyToRun); + Assert.IsTrue(analyzer.HasManagedMetadata, "an R2R image keeps its ECMA-335 metadata"); + Assert.IsTrue(analyzer.HasEmbeddedNativeCode); var info = analyzer.ReadyToRunInfo; - Assert.NotNull(info); - Assert.Equal(ReadyToRunStatus.Valid, info!.Status); - Assert.False(info.IsComposite); - Assert.False(info.IsComponent); + Assert.IsNotNull(info); + Assert.AreEqual(ReadyToRunStatus.Valid, info!.Status); + Assert.IsFalse(info.IsComposite); + Assert.IsFalse(info.IsComponent); // Architecture is precise, never Unknown. - Assert.NotEqual(NativeArchitecture.Unknown, info.Architecture); + Assert.AreNotEqual(NativeArchitecture.Unknown, info.Architecture); // The code image for a non-composite image is itself. - Assert.Same(analyzer, analyzer.ReadyToRunCodeImage); + Assert.AreSame(analyzer, analyzer.ReadyToRunCodeImage); } /// Unix R2R images use CoreCLR's native-image machine encoding, not plain COFF values. - [Theory(Timeout = 30_000)] - [InlineData(0xFD1D, NativeArchitecture.X64)] // AMD64 ^ Linux override - [InlineData(0xC020, NativeArchitecture.X64)] // AMD64 ^ Apple override - [InlineData(0xD11D, NativeArchitecture.Arm64)] // ARM64 ^ Linux override - [InlineData(0xEC20, NativeArchitecture.Arm64)] // ARM64 ^ Apple override + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow((ushort)0xFD1D, NativeArchitecture.X64)] // AMD64 ^ Linux override + [DataRow((ushort)0xC020, NativeArchitecture.X64)] // AMD64 ^ Apple override + [DataRow((ushort)0xD11D, NativeArchitecture.Arm64)] // ARM64 ^ Linux override + [DataRow((ushort)0xEC20, NativeArchitecture.Arm64)] // ARM64 ^ Apple override public void NativeImageMachineValues_MapToRealArchitecture(ushort machine, NativeArchitecture expected) { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); - var bytes = PatchPeMachine(samples.ReadyToRunConsoleDll!, machine); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); + var bytes = PatchPeMachine(Samples.ReadyToRunConsoleDll!, machine); - using var analyzer = new AssemblyAnalyzer(bytes, samples.ReadyToRunConsoleDll!); + using var analyzer = new AssemblyAnalyzer(bytes, Samples.ReadyToRunConsoleDll!); - Assert.Equal(BinaryKind.ReadyToRun, analyzer.BinaryKind); - Assert.Equal(ReadyToRunStatus.Valid, analyzer.ReadyToRunInfo!.Status); - Assert.Equal(expected, analyzer.ReadyToRunInfo.Architecture); + Assert.AreEqual(BinaryKind.ReadyToRun, analyzer.BinaryKind); + Assert.AreEqual(ReadyToRunStatus.Valid, analyzer.ReadyToRunInfo!.Status); + Assert.AreEqual(expected, analyzer.ReadyToRunInfo.Architecture); } /// The real image's major version falls within the supported ceiling and reads as Valid. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ValidImage_MajorVersion_IsWithinSupportedCeiling() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleDll!); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleDll!); var info = analyzer.ReadyToRunInfo; - Assert.NotNull(info); + Assert.IsNotNull(info); // The acceptance rule: a major within the supported inspection window reads as Valid. - Assert.InRange( - info!.MajorVersion, - ClassicReadyToRunDetector.MinimumInspectableMajorVersion, - ClassicReadyToRunDetector.CurrentMajorVersion); - Assert.Equal(ReadyToRunStatus.Valid, info.Status); - Assert.Null(info.Diagnostic); + Assert.IsInRange(ClassicReadyToRunDetector.MinimumInspectableMajorVersion, ClassicReadyToRunDetector.CurrentMajorVersion, info!.MajorVersion); + Assert.AreEqual(ReadyToRunStatus.Valid, info.Status); + Assert.IsNull(info.Diagnostic); } /// A valid image's native symbols come from the ReadyToRun source and load cleanly. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ValidImage_NativeSymbols_LoadedFromReadyToRun() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleDll!); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleDll!); var symbols = analyzer.NativeSymbols; - Assert.NotNull(symbols); - Assert.Equal(NativeSymbolSource.ReadyToRun, symbols!.Source); - Assert.Equal(NativeSymbolStatus.Loaded, symbols.Status); - Assert.NotEmpty(symbols.Symbols); + Assert.IsNotNull(symbols); + Assert.AreEqual(NativeSymbolSource.ReadyToRun, symbols!.Source); + Assert.AreEqual(NativeSymbolStatus.Loaded, symbols.Status); + Assert.IsNotEmpty(symbols.Symbols); } /// The R2R apphost sits beside a managed companion that is itself the ReadyToRun image. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Apphost_HasReadyToRunCompanionBeside() { - Assert.SkipWhen(samples.ReadyToRunConsoleExe is null, SkipReason); + TestSkip.When(Samples.ReadyToRunConsoleExe is null, SkipReason); // The apphost launcher sits beside its managed companion; the companion is the R2R image. var companion = Path.Combine( - Path.GetDirectoryName(samples.ReadyToRunConsoleExe!)!, "ReadyToRunConsole.dll"); - Assert.True(File.Exists(companion), "the R2R companion must sit beside its apphost"); + Path.GetDirectoryName(Samples.ReadyToRunConsoleExe!)!, "ReadyToRunConsole.dll"); + Assert.IsTrue(File.Exists(companion), "the R2R companion must sit beside its apphost"); using var analyzer = new AssemblyAnalyzer(companion); - Assert.True(analyzer.IsReadyToRun); - Assert.Equal(ReadyToRunStatus.Valid, analyzer.ReadyToRunInfo!.Status); + Assert.IsTrue(analyzer.IsReadyToRun); + Assert.AreEqual(ReadyToRunStatus.Valid, analyzer.ReadyToRunInfo!.Status); } /// A plain managed library has no R2R header and stays classified as managed. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PlainManaged_IsNotReadyToRun() { - using var analyzer = new AssemblyAnalyzer(samples.RichLibraryDll); - Assert.Null(analyzer.ReadyToRunInfo); - Assert.False(analyzer.IsReadyToRun); - Assert.Equal(BinaryKind.Managed, analyzer.BinaryKind); + using var analyzer = new AssemblyAnalyzer(Samples.RichLibraryDll); + Assert.IsNull(analyzer.ReadyToRunInfo); + Assert.IsFalse(analyzer.IsReadyToRun); + Assert.AreEqual(BinaryKind.Managed, analyzer.BinaryKind); } // --- Malformed states no real crossgen2 publish can produce. Per the approved approach, these @@ -121,71 +126,75 @@ public void PlainManaged_IsNotReadyToRun() // production parse (verified against the baseline major), never a hand-built image, never committed. /// A major below the inspection floor is surfaced as unsupported and parses no tables. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void MajorVersion_BelowFloor_IsUnsupportedAndParsesNothing() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); - var bytes = PatchMajorVersion(samples.ReadyToRunConsoleDll!, + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); + var bytes = PatchMajorVersion(Samples.ReadyToRunConsoleDll!, (ushort)(ClassicReadyToRunDetector.MinimumInspectableMajorVersion - 1)); - using var analyzer = new AssemblyAnalyzer(bytes, samples.ReadyToRunConsoleDll!); + using var analyzer = new AssemblyAnalyzer(bytes, Samples.ReadyToRunConsoleDll!); var info = analyzer.ReadyToRunInfo; - Assert.NotNull(info); - Assert.Equal(ReadyToRunStatus.UnsupportedVersion, info!.Status); + Assert.IsNotNull(info); + Assert.AreEqual(ReadyToRunStatus.UnsupportedVersion, info!.Status); // Still surfaced as ReadyToRun with a diagnostic, never silently managed. - Assert.Equal(BinaryKind.ReadyToRun, analyzer.BinaryKind); - Assert.NotNull(info.Diagnostic); + Assert.AreEqual(BinaryKind.ReadyToRun, analyzer.BinaryKind); + Assert.IsNotNull(info.Diagnostic); AssertNoTablesParsed(analyzer); } /// A major above the current version is surfaced as unsupported and parses no tables. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void MajorVersion_AboveCurrent_IsUnsupportedAndParsesNothing() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); - var bytes = PatchMajorVersion(samples.ReadyToRunConsoleDll!, + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); + var bytes = PatchMajorVersion(Samples.ReadyToRunConsoleDll!, (ushort)(ClassicReadyToRunDetector.CurrentMajorVersion + 1)); - using var analyzer = new AssemblyAnalyzer(bytes, samples.ReadyToRunConsoleDll!); + using var analyzer = new AssemblyAnalyzer(bytes, Samples.ReadyToRunConsoleDll!); var info = analyzer.ReadyToRunInfo; - Assert.NotNull(info); - Assert.Equal(ReadyToRunStatus.UnsupportedVersion, info!.Status); - Assert.Equal(BinaryKind.ReadyToRun, analyzer.BinaryKind); + Assert.IsNotNull(info); + Assert.AreEqual(ReadyToRunStatus.UnsupportedVersion, info!.Status); + Assert.AreEqual(BinaryKind.ReadyToRun, analyzer.BinaryKind); AssertNoTablesParsed(analyzer); } /// A section table claiming more rows than the file holds is corrupt and parses no tables. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void TruncatedSectionTable_IsCorruptAndParsesNothing() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); - var (bytes, header) = LoadAndLocateHeader(samples.ReadyToRunConsoleDll!); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); + var (bytes, header) = LoadAndLocateHeader(Samples.ReadyToRunConsoleDll!); // Section count is the u32 after signature(4) + major(2) + minor(2) + flags(4). Claim more rows // than the file can hold (bounded by the reader's 4096 guard) so the parse detects truncation. var maxRows = (bytes.Length - (header + 20)) / 12; var claimed = Math.Min(4096, maxRows + 64); BitConverter.GetBytes(claimed).CopyTo(bytes, header + 12); - using var analyzer = new AssemblyAnalyzer(bytes, samples.ReadyToRunConsoleDll!); + using var analyzer = new AssemblyAnalyzer(bytes, Samples.ReadyToRunConsoleDll!); var info = analyzer.ReadyToRunInfo; - Assert.NotNull(info); - Assert.Equal(ReadyToRunStatus.Corrupt, info!.Status); - Assert.Equal(BinaryKind.ReadyToRun, analyzer.BinaryKind); - Assert.NotNull(info.Diagnostic); + Assert.IsNotNull(info); + Assert.AreEqual(ReadyToRunStatus.Corrupt, info!.Status); + Assert.AreEqual(BinaryKind.ReadyToRun, analyzer.BinaryKind); + Assert.IsNotNull(info.Diagnostic); AssertNoTablesParsed(analyzer); } // A non-Valid image must expose header diagnostics only — no map, no loaded symbols, no correlation. private static void AssertNoTablesParsed(AssemblyAnalyzer analyzer) { - Assert.Empty(analyzer.ReadyToRunMethods); - Assert.Null(analyzer.ReadyToRunIndex); + Assert.IsEmpty(analyzer.ReadyToRunMethods); + Assert.IsNull(analyzer.ReadyToRunIndex); // Symbols carry a diagnostic status, never a misleading empty "loaded" set. - Assert.NotEqual(NativeSymbolStatus.Loaded, analyzer.NativeSymbols?.Status); + Assert.IsNotNull(analyzer.NativeSymbols); + Assert.AreNotEqual(NativeSymbolStatus.Loaded, analyzer.NativeSymbols.Status); // Correlation refuses rather than reading a body out of an untrusted layout. var result = ReadyToRunCorrelationQuery.Resolve( - analyzer, "get_Name", TestContext.Current.CancellationToken); - Assert.Equal(ReadyToRunQueryOutcome.Unavailable, result.Outcome); + analyzer, "get_Name", CancellationToken.None); + Assert.AreEqual(ReadyToRunQueryOutcome.Unavailable, result.Outcome); } // The RTR signature dword is the ASCII bytes 'R','T','R','\0'. @@ -213,8 +222,8 @@ private static (byte[] Bytes, int HeaderOffset) LoadAndLocateHeader(string path) var bytes = File.ReadAllBytes(path); using var analyzer = new AssemblyAnalyzer(path); var info = analyzer.ReadyToRunInfo; - Assert.NotNull(info); - Assert.Equal(ReadyToRunStatus.Valid, info!.Status); // baseline parse proves the header is real + Assert.IsNotNull(info); + Assert.AreEqual(ReadyToRunStatus.Valid, info!.Status); // baseline parse proves the header is real for (var i = 0; i + 6 <= bytes.Length; i++) { @@ -232,10 +241,10 @@ private static (byte[] Bytes, int HeaderOffset) LoadAndLocateHeader(string path) private static int LocatePeMachineOffset(byte[] bytes) { - Assert.True(bytes.Length >= 0x40, "fixture is too small for a PE header"); + Assert.IsGreaterThanOrEqualTo(0x40, bytes.Length, "fixture is too small for a PE header"); var peOffset = BitConverter.ToInt32(bytes, 0x3C); - Assert.InRange(peOffset, 0, bytes.Length - 6); - Assert.Equal(0x0000_4550u, BitConverter.ToUInt32(bytes, peOffset)); + Assert.IsInRange(0, bytes.Length - 6, peOffset); + Assert.AreEqual(0x0000_4550u, BitConverter.ToUInt32(bytes, peOffset)); return peOffset + 4; } } diff --git a/tests/Dotsider.Tests/ReadyToRunArchitectureDecoderTests.cs b/tests/Dotsider.Tests/ReadyToRunArchitectureDecoderTests.cs index fc0a58be..ba521754 100644 --- a/tests/Dotsider.Tests/ReadyToRunArchitectureDecoderTests.cs +++ b/tests/Dotsider.Tests/ReadyToRunArchitectureDecoderTests.cs @@ -9,9 +9,11 @@ namespace Dotsider.Tests; /// The shared fixture publishes cross-RID sample assemblies when the SDK has the required packs. /// These tests catch architecture routing and padding mistakes that synthetic byte tests cannot. /// -[Collection("SampleAssemblies")] -public class ReadyToRunArchitectureDecoderTests(SampleAssemblyFixture samples) +[TestClass] +public class ReadyToRunArchitectureDecoderTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private const string SkipReason = "ReadyToRun cross-RID publish did not run on this leg."; /// @@ -19,20 +21,21 @@ public class ReadyToRunArchitectureDecoderTests(SampleAssemblyFixture samples) /// The image is produced by crossgen2, not by hand-authored bytes. /// Fallback instructions would mean a valid method range was not decoded. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void X86_RealReadyToRunMethod_DecodesWithoutFallback() { - Assert.SkipWhen(samples.ReadyToRunConsoleX86Dll is null, SkipReason); + TestSkip.When(Samples.ReadyToRunConsoleX86Dll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleX86Dll!); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleX86Dll!); var report = ResolveGreeterName(analyzer); - Assert.Equal(NativeArchitecture.X86, analyzer.ReadyToRunInfo!.Architecture); - Assert.Equal(ReadyToRunNativeAvailability.Precompiled, report.Availability); - Assert.NotNull(report.NativeInstructions); - Assert.Contains(report.NativeInstructions!, i => i.Mnemonic == "mov"); - Assert.Contains(report.NativeInstructions!, i => i.Mnemonic == "ret"); - Assert.DoesNotContain(report.NativeInstructions!, i => i.IsFallback); + Assert.AreEqual(NativeArchitecture.X86, analyzer.ReadyToRunInfo!.Architecture); + Assert.AreEqual(ReadyToRunNativeAvailability.Precompiled, report.Availability); + Assert.IsNotNull(report.NativeInstructions); + Assert.Contains(i => i.Mnemonic == "mov", report.NativeInstructions!); + Assert.Contains(i => i.Mnemonic == "ret", report.NativeInstructions!); + Assert.DoesNotContain(i => i.IsFallback, report.NativeInstructions!); } /// @@ -40,12 +43,13 @@ public void X86_RealReadyToRunMethod_DecodesWithoutFallback() /// The range list comes from the real runtime-function table. /// This catches emitted patterns outside the one-method smoke path. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void X86_RealReadyToRunRanges_AreLengthExactWithoutFallback() { - Assert.SkipWhen(samples.ReadyToRunConsoleX86Dll is null, SkipReason); + TestSkip.When(Samples.ReadyToRunConsoleX86Dll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleX86Dll!); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleX86Dll!); AssertRealReadyToRunRangesDecode(analyzer, NativeArchitecture.X86); } @@ -55,21 +59,22 @@ public void X86_RealReadyToRunRanges_AreLengthExactWithoutFallback() /// The image is produced by crossgen2, and the R2R unwind length excludes later trap padding. /// Fallback instructions would mean valid Thumb code in the method body was not modeled. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Arm32_RealReadyToRunMethod_DecodesWithoutFallback() { - Assert.SkipWhen(samples.ReadyToRunConsoleArm32Dll is null, SkipReason); + TestSkip.When(Samples.ReadyToRunConsoleArm32Dll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleArm32Dll!); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleArm32Dll!); var report = ResolveGreeterName(analyzer); - Assert.Equal(NativeArchitecture.Arm32, analyzer.ReadyToRunInfo!.Architecture); - Assert.Equal(ReadyToRunNativeAvailability.Precompiled, report.Availability); - Assert.NotNull(report.NativeInstructions); - Assert.Contains(report.NativeInstructions!, i => i.Mnemonic == "push"); - Assert.Contains(report.NativeInstructions!, i => i.Mnemonic == "ldr"); - Assert.Contains(report.NativeInstructions!, i => i.Mnemonic == "pop"); - Assert.DoesNotContain(report.NativeInstructions!, i => i.IsFallback); + Assert.AreEqual(NativeArchitecture.Arm32, analyzer.ReadyToRunInfo!.Architecture); + Assert.AreEqual(ReadyToRunNativeAvailability.Precompiled, report.Availability); + Assert.IsNotNull(report.NativeInstructions); + Assert.Contains(i => i.Mnemonic == "push", report.NativeInstructions!); + Assert.Contains(i => i.Mnemonic == "ldr", report.NativeInstructions!); + Assert.Contains(i => i.Mnemonic == "pop", report.NativeInstructions!); + Assert.DoesNotContain(i => i.IsFallback, report.NativeInstructions!); } /// @@ -77,12 +82,13 @@ public void Arm32_RealReadyToRunMethod_DecodesWithoutFallback() /// The range list comes from the real runtime-function table. /// This catches mixed-width Thumb patterns outside the one-method smoke path. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Arm32_RealReadyToRunRanges_AreLengthExactWithoutFallback() { - Assert.SkipWhen(samples.ReadyToRunConsoleArm32Dll is null, SkipReason); + TestSkip.When(Samples.ReadyToRunConsoleArm32Dll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleArm32Dll!); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleArm32Dll!); AssertRealReadyToRunRangesDecode(analyzer, NativeArchitecture.Arm32); } @@ -92,19 +98,20 @@ public void Arm32_RealReadyToRunRanges_AreLengthExactWithoutFallback() /// Public SDK feeds do not always ship this RID, so the fixture path is optional. /// When present, the image is crossgen2 output rather than a hand-authored byte fixture. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RiscV64_RealReadyToRunMethod_DecodesWithoutFallback() { - Assert.SkipWhen(samples.ReadyToRunConsoleRiscV64Dll is null, SkipReason); + TestSkip.When(Samples.ReadyToRunConsoleRiscV64Dll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleRiscV64Dll!); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleRiscV64Dll!); var report = ResolveGreeterName(analyzer); - Assert.Equal(NativeArchitecture.RiscV64, analyzer.ReadyToRunInfo!.Architecture); - Assert.Equal(ReadyToRunNativeAvailability.Precompiled, report.Availability); - Assert.NotNull(report.NativeInstructions); - Assert.NotEmpty(report.NativeInstructions!); - Assert.DoesNotContain(report.NativeInstructions!, i => i.IsFallback); + Assert.AreEqual(NativeArchitecture.RiscV64, analyzer.ReadyToRunInfo!.Architecture); + Assert.AreEqual(ReadyToRunNativeAvailability.Precompiled, report.Availability); + Assert.IsNotNull(report.NativeInstructions); + Assert.IsNotEmpty(report.NativeInstructions!); + Assert.DoesNotContain(i => i.IsFallback, report.NativeInstructions!); } /// @@ -112,12 +119,13 @@ public void RiscV64_RealReadyToRunMethod_DecodesWithoutFallback() /// The range list comes from a real runtime-function table when the SDK can publish the RID. /// The committed oracle fixtures cover this decoder on SDKs that lack the RID packs. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RiscV64_RealReadyToRunRanges_AreLengthExactWithoutFallback() { - Assert.SkipWhen(samples.ReadyToRunConsoleRiscV64Dll is null, SkipReason); + TestSkip.When(Samples.ReadyToRunConsoleRiscV64Dll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleRiscV64Dll!); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleRiscV64Dll!); AssertRealReadyToRunRangesDecode(analyzer, NativeArchitecture.RiscV64); } @@ -127,19 +135,20 @@ public void RiscV64_RealReadyToRunRanges_AreLengthExactWithoutFallback() /// Public SDK feeds do not always ship this RID, so the fixture path is optional. /// When present, the image is crossgen2 output rather than a hand-authored byte fixture. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void LoongArch64_RealReadyToRunMethod_DecodesWithoutFallback() { - Assert.SkipWhen(samples.ReadyToRunConsoleLoongArch64Dll is null, SkipReason); + TestSkip.When(Samples.ReadyToRunConsoleLoongArch64Dll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleLoongArch64Dll!); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleLoongArch64Dll!); var report = ResolveGreeterName(analyzer); - Assert.Equal(NativeArchitecture.LoongArch64, analyzer.ReadyToRunInfo!.Architecture); - Assert.Equal(ReadyToRunNativeAvailability.Precompiled, report.Availability); - Assert.NotNull(report.NativeInstructions); - Assert.NotEmpty(report.NativeInstructions!); - Assert.DoesNotContain(report.NativeInstructions!, i => i.IsFallback); + Assert.AreEqual(NativeArchitecture.LoongArch64, analyzer.ReadyToRunInfo!.Architecture); + Assert.AreEqual(ReadyToRunNativeAvailability.Precompiled, report.Availability); + Assert.IsNotNull(report.NativeInstructions); + Assert.IsNotEmpty(report.NativeInstructions!); + Assert.DoesNotContain(i => i.IsFallback, report.NativeInstructions!); } /// @@ -147,12 +156,13 @@ public void LoongArch64_RealReadyToRunMethod_DecodesWithoutFallback() /// The range list comes from a real runtime-function table when the SDK can publish the RID. /// The committed oracle fixtures cover this decoder on SDKs that lack the RID packs. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void LoongArch64_RealReadyToRunRanges_AreLengthExactWithoutFallback() { - Assert.SkipWhen(samples.ReadyToRunConsoleLoongArch64Dll is null, SkipReason); + TestSkip.When(Samples.ReadyToRunConsoleLoongArch64Dll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleLoongArch64Dll!); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleLoongArch64Dll!); AssertRealReadyToRunRangesDecode(analyzer, NativeArchitecture.LoongArch64); } @@ -160,16 +170,16 @@ public void LoongArch64_RealReadyToRunRanges_AreLengthExactWithoutFallback() private static ReadyToRunMethodReport ResolveGreeterName(AssemblyAnalyzer analyzer) { var result = ReadyToRunCorrelationQuery.Resolve( - analyzer, "Greeter.get_Name", TestContext.Current.CancellationToken); + analyzer, "Greeter.get_Name", CancellationToken.None); - Assert.Equal(ReadyToRunQueryOutcome.Resolved, result.Outcome); - Assert.NotNull(result.Report); + Assert.AreEqual(ReadyToRunQueryOutcome.Resolved, result.Outcome); + Assert.IsNotNull(result.Report); return result.Report!; } private static void AssertRealReadyToRunRangesDecode(AssemblyAnalyzer analyzer, NativeArchitecture expected) { - Assert.Equal(expected, analyzer.ReadyToRunInfo!.Architecture); + Assert.AreEqual(expected, analyzer.ReadyToRunInfo!.Architecture); var failures = new List(); foreach (var method in analyzer.ReadyToRunMethods) @@ -194,6 +204,6 @@ private static void AssertRealReadyToRunRangesDecode(AssemblyAnalyzer analyzer, } } - Assert.True(failures.Count == 0, string.Join(Environment.NewLine, failures.Take(20))); + Assert.IsEmpty(failures, string.Join(Environment.NewLine, failures.Take(20))); } } diff --git a/tests/Dotsider.Tests/ReadyToRunCompositeTests.cs b/tests/Dotsider.Tests/ReadyToRunCompositeTests.cs index 447e078a..445f0e61 100644 --- a/tests/Dotsider.Tests/ReadyToRunCompositeTests.cs +++ b/tests/Dotsider.Tests/ReadyToRunCompositeTests.cs @@ -11,139 +11,147 @@ namespace Dotsider.Tests; /// ComponentMetadataUnavailable — are exercised by relocating a real image away from the /// siblings it needs, never by fabricating bytes. /// -[Collection("SampleAssemblies")] -public class ReadyToRunCompositeTests(SampleAssemblyFixture samples) +[TestClass] +public class ReadyToRunCompositeTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private const string SkipReason = "ReadyToRun composite publish did not run on this leg."; /// Opened directly, a composite resolves its components from siblings by name and MVID. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GlobalComposite_ResolvesComponentsByNameAndMvid() { - Assert.SkipWhen(samples.ReadyToRunCompositeImage is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunCompositeImage!); + TestSkip.When(Samples.ReadyToRunCompositeImage is null, SkipReason); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunCompositeImage!); var info = analyzer.ReadyToRunInfo; - Assert.NotNull(info); - Assert.True(info!.IsComposite); - Assert.True(analyzer.IsReadyToRun); + Assert.IsNotNull(info); + Assert.IsTrue(info!.IsComposite); + Assert.IsTrue(analyzer.IsReadyToRun); // Code lives in the composite itself. - Assert.Same(analyzer, analyzer.ReadyToRunCodeImage); + Assert.AreSame(analyzer, analyzer.ReadyToRunCodeImage); // The app components resolve from siblings; identity is validated by MVID, not just name. var lib = analyzer.ReadyToRunComponents.FirstOrDefault( - c => c.Mvid == samples.ReadyToRunComponentLibMvid); - Assert.NotNull(lib); - Assert.True(lib!.MetadataAvailable, "the component beside the composite must resolve by MVID"); - Assert.Equal("ReadyToRunComponentLib.dll", Path.GetFileName(lib.ResolvedPath)); + c => c.Mvid == Samples.ReadyToRunComponentLibMvid); + Assert.IsNotNull(lib); + Assert.IsTrue(lib!.MetadataAvailable, "the component beside the composite must resolve by MVID"); + Assert.AreEqual("ReadyToRunComponentLib.dll", Path.GetFileName(lib.ResolvedPath)); // A method from that component is named through the resolved metadata and is precompiled. var add = analyzer.ReadyToRunMethods.FirstOrDefault( m => m.Name == "Add" && m.DeclaringType is not null && m.DeclaringType.Contains("Calculator")); - Assert.NotNull(add); - Assert.NotEmpty(add!.CodeRanges); + Assert.IsNotNull(add); + Assert.IsNotEmpty(add!.CodeRanges); } /// A component DLL routes its native code image to the owner composite (a different file). /// A composite's instantiated generics are attributed to their owning component with names. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GlobalComposite_GenericInstantiations_AreOwnedByComponents() { - Assert.SkipWhen(samples.ReadyToRunCompositeImage is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunCompositeImage!); + TestSkip.When(Samples.ReadyToRunCompositeImage is null, SkipReason); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunCompositeImage!); // A module override inside the instance signature identifies the owning component; the // instantiation resolves there rather than being an unnamed manifest method. var owned = analyzer.ReadyToRunMethods .Where(m => m.IsGenericInstantiation && m.DeclaringType is not null && m.Mvid != Guid.Empty) .ToList(); - Assert.NotEmpty(owned); + Assert.IsNotEmpty(owned); // A component-owned instantiation carries that component's MVID (not the empty manifest MVID). var componentMvids = analyzer.ReadyToRunComponents .Where(c => c.MetadataAvailable).Select(c => c.Mvid).ToHashSet(); - Assert.Contains(owned, g => componentMvids.Contains(g.Mvid)); + Assert.Contains(g => componentMvids.Contains(g.Mvid), owned); } /// The composite Size Map attributes native code across its components (not a zero tree). - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GlobalComposite_SizeMap_SpansComponents() { - Assert.SkipWhen(samples.ReadyToRunCompositeImage is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunCompositeImage!); + TestSkip.When(Samples.ReadyToRunCompositeImage is null, SkipReason); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunCompositeImage!); // The composite has no own metadata; the size tree must be built from the component-resolved // method entries, so it is non-zero and spans more than one assembly subtree. var tree = SizeAnalyzer.BuildSizeTree(analyzer); - Assert.True(tree.Size > 0, "a composite size tree must carry the precompiled native bytes"); - Assert.True(tree.Children.Count > 1, "a composite size tree should span multiple component assemblies"); + Assert.IsGreaterThan(0, tree.Size, "a composite size tree must carry the precompiled native bytes"); + Assert.IsGreaterThan(1, tree.Children.Count, "a composite size tree should span multiple component assemblies"); } /// A component's precompiled native resolves cross-module call targets through the manifest. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Component_Native_ResolvesCrossModuleCallTargets() { - Assert.SkipWhen(samples.ReadyToRunCompositeComponent is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunCompositeComponent!); + TestSkip.When(Samples.ReadyToRunCompositeComponent is null, SkipReason); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunCompositeComponent!); // Program.
$ calls into other components (Console, the component library); the import // resolver names those cross-module targets rather than leaving bare addresses. var result = ReadyToRunCorrelationQuery.Resolve( - analyzer, "
$", TestContext.Current.CancellationToken); - Assert.Equal(ReadyToRunQueryOutcome.Resolved, result.Outcome); - Assert.Equal(ReadyToRunNativeAvailability.Precompiled, result.Report!.Availability); - Assert.NotNull(result.Report.NativeText); + analyzer, "
$", CancellationToken.None); + Assert.AreEqual(ReadyToRunQueryOutcome.Resolved, result.Outcome); + Assert.AreEqual(ReadyToRunNativeAvailability.Precompiled, result.Report!.Availability); + Assert.IsNotNull(result.Report.NativeText); // The disassembly names a call target rather than only raw hex addresses. Assert.Contains("WriteLine", result.Report.NativeText); } /// A component DLL routes its native code image to the owner composite (a different file). - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Component_RoutesCodeImageToOwnerComposite() { - Assert.SkipWhen(samples.ReadyToRunComponentLibDll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunComponentLibDll!); + TestSkip.When(Samples.ReadyToRunComponentLibDll is null, SkipReason); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunComponentLibDll!); var info = analyzer.ReadyToRunInfo; - Assert.NotNull(info); - Assert.True(info!.IsComponent); - Assert.Equal("ReadyToRunComposite.r2r.dll", info.OwnerCompositeExecutable); + Assert.IsNotNull(info); + Assert.IsTrue(info!.IsComponent); + Assert.AreEqual("ReadyToRunComposite.r2r.dll", info.OwnerCompositeExecutable); // The native code image is the owner composite — a different file than this component. var codeImage = analyzer.ReadyToRunCodeImage; - Assert.NotNull(codeImage); - Assert.Equal("ReadyToRunComposite.r2r.dll", Path.GetFileName(codeImage!.FilePath)); - Assert.NotSame(analyzer, codeImage); + Assert.IsNotNull(codeImage); + Assert.AreEqual("ReadyToRunComposite.r2r.dll", Path.GetFileName(codeImage!.FilePath)); + Assert.AreNotSame(analyzer, codeImage); // Its precompiled methods are the ones owned by this component. - Assert.NotEmpty(analyzer.ReadyToRunMethods); - Assert.All(analyzer.ReadyToRunMethods, - m => Assert.Equal(samples.ReadyToRunComponentLibMvid, m.Mvid)); + Assert.IsNotEmpty(analyzer.ReadyToRunMethods); + TestAssert.All(analyzer.ReadyToRunMethods, + m => Assert.AreEqual(Samples.ReadyToRunComponentLibMvid, m.Mvid)); } /// A component relocated away from its owner composite reports OwnerCompositeMissing, not IL-only. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Component_WithoutOwnerComposite_IsOwnerCompositeMissing() { - Assert.SkipWhen(samples.ReadyToRunComponentLibDll is null, SkipReason); + TestSkip.When(Samples.ReadyToRunComponentLibDll is null, SkipReason); // Relocate the real component DLL away from its owner composite: a genuine "owner missing" case. var temp = Path.Combine(Path.GetTempPath(), $"r2r-orphan-{Guid.NewGuid():N}"); Directory.CreateDirectory(temp); try { - var orphan = Path.Combine(temp, Path.GetFileName(samples.ReadyToRunComponentLibDll!)); - File.Copy(samples.ReadyToRunComponentLibDll!, orphan); + var orphan = Path.Combine(temp, Path.GetFileName(Samples.ReadyToRunComponentLibDll!)); + File.Copy(Samples.ReadyToRunComponentLibDll!, orphan); using var analyzer = new AssemblyAnalyzer(orphan); - Assert.True(analyzer.ReadyToRunInfo!.IsComponent); + Assert.IsTrue(analyzer.ReadyToRunInfo!.IsComponent); // No owner composite on disk → no code image, distinct from "not precompiled". - Assert.Null(analyzer.ReadyToRunCodeImage); + Assert.IsNull(analyzer.ReadyToRunCodeImage); var result = ReadyToRunCorrelationQuery.Resolve( - analyzer, "Calculator.Add", TestContext.Current.CancellationToken); - Assert.Equal(ReadyToRunQueryOutcome.Resolved, result.Outcome); - Assert.Equal(ReadyToRunNativeAvailability.OwnerCompositeMissing, result.Report!.Availability); + analyzer, "Calculator.Add", CancellationToken.None); + Assert.AreEqual(ReadyToRunQueryOutcome.Resolved, result.Outcome); + Assert.AreEqual(ReadyToRunNativeAvailability.OwnerCompositeMissing, result.Report!.Availability); } finally { @@ -152,33 +160,34 @@ public void Component_WithoutOwnerComposite_IsOwnerCompositeMissing() } /// A composite relocated away from its siblings reports ComponentMetadataUnavailable, not IL-only. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Composite_WithoutSiblings_IsComponentMetadataUnavailable() { - Assert.SkipWhen(samples.ReadyToRunCompositeImage is null, SkipReason); + TestSkip.When(Samples.ReadyToRunCompositeImage is null, SkipReason); // Relocate only the composite away from its component siblings: metadata can't be resolved. var temp = Path.Combine(Path.GetTempPath(), $"r2r-lonely-{Guid.NewGuid():N}"); Directory.CreateDirectory(temp); try { - var lonely = Path.Combine(temp, Path.GetFileName(samples.ReadyToRunCompositeImage!)); - File.Copy(samples.ReadyToRunCompositeImage!, lonely); + var lonely = Path.Combine(temp, Path.GetFileName(Samples.ReadyToRunCompositeImage!)); + File.Copy(Samples.ReadyToRunCompositeImage!, lonely); using var analyzer = new AssemblyAnalyzer(lonely); - Assert.True(analyzer.ReadyToRunInfo!.IsComposite); + Assert.IsTrue(analyzer.ReadyToRunInfo!.IsComposite); // Every component is unresolved without its sibling metadata. - Assert.NotEmpty(analyzer.ReadyToRunComponents); - Assert.All(analyzer.ReadyToRunComponents, c => Assert.False(c.MetadataAvailable)); + Assert.IsNotEmpty(analyzer.ReadyToRunComponents); + TestAssert.All(analyzer.ReadyToRunComponents, c => Assert.IsFalse(c.MetadataAvailable)); // A precompiled method still has native code; correlating by its address surfaces the // distinct "component metadata unavailable" state rather than a generic IL-only one. var withCode = analyzer.ReadyToRunMethods.First(m => m.CodeRanges.Count > 0); var address = withCode.CodeRanges[0].VirtualAddress; var result = ReadyToRunCorrelationQuery.Resolve( - analyzer, $"0x{address:x}", TestContext.Current.CancellationToken); - Assert.Equal(ReadyToRunQueryOutcome.Resolved, result.Outcome); - Assert.Equal(ReadyToRunNativeAvailability.ComponentMetadataUnavailable, result.Report!.Availability); + analyzer, $"0x{address:x}", CancellationToken.None); + Assert.AreEqual(ReadyToRunQueryOutcome.Resolved, result.Outcome); + Assert.AreEqual(ReadyToRunNativeAvailability.ComponentMetadataUnavailable, result.Report!.Availability); } finally { diff --git a/tests/Dotsider.Tests/ReadyToRunCorrelationTests.cs b/tests/Dotsider.Tests/ReadyToRunCorrelationTests.cs index b7229da0..3c160001 100644 --- a/tests/Dotsider.Tests/ReadyToRunCorrelationTests.cs +++ b/tests/Dotsider.Tests/ReadyToRunCorrelationTests.cs @@ -9,83 +9,90 @@ namespace Dotsider.Tests; /// all resolve to a per-range report; an overloaded name lists candidates instead of guessing; a /// precompiled method's native disassembly names its indirect call targets through the import map. /// -[Collection("SampleAssemblies")] -public class ReadyToRunCorrelationTests(SampleAssemblyFixture samples) +[TestClass] +public class ReadyToRunCorrelationTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private const string SkipReason = "ReadyToRun crossgen2 publish did not run on this leg."; /// A unique method name resolves to a precompiled report with native code and ranges. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ByName_Unique_ResolvesPrecompiled() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleDll!); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleDll!); var result = ReadyToRunCorrelationQuery.Resolve( - analyzer, "Greeter.get_Name", TestContext.Current.CancellationToken); + analyzer, "Greeter.get_Name", CancellationToken.None); - Assert.Equal(ReadyToRunQueryOutcome.Resolved, result.Outcome); - Assert.Equal(ReadyToRunNativeAvailability.Precompiled, result.Report!.Availability); - Assert.NotEmpty(result.Report.Ranges); - Assert.NotNull(result.Report.NativeText); - Assert.NotNull(result.Report.Il); + Assert.AreEqual(ReadyToRunQueryOutcome.Resolved, result.Outcome); + Assert.AreEqual(ReadyToRunNativeAvailability.Precompiled, result.Report!.Availability); + Assert.IsNotEmpty(result.Report.Ranges); + Assert.IsNotNull(result.Report.NativeText); + Assert.IsNotNull(result.Report.Il); } /// An overloaded name is reported ambiguous with candidates, never first-match. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ByName_Overloaded_IsAmbiguous() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleDll!); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleDll!); // Greeter.Greet has two overloads in the precompiled map. var result = ReadyToRunCorrelationQuery.Resolve( - analyzer, "Greet", TestContext.Current.CancellationToken); + analyzer, "Greet", CancellationToken.None); - Assert.Equal(ReadyToRunQueryOutcome.Ambiguous, result.Outcome); - Assert.True(result.Candidates.Count >= 2); - Assert.Null(result.Report); + Assert.AreEqual(ReadyToRunQueryOutcome.Ambiguous, result.Outcome); + Assert.IsGreaterThanOrEqualTo(2, result.Candidates.Count); + Assert.IsNull(result.Report); } /// A native address resolves through its containing method's ranges. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ByAddress_ResolvesContainingMethod() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleDll!); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleDll!); var method = analyzer.ReadyToRunMethods.First(m => m.CodeRanges.Count > 0); var address = method.CodeRanges[0].VirtualAddress; var result = ReadyToRunCorrelationQuery.Resolve( - analyzer, $"0x{address:x}", TestContext.Current.CancellationToken); + analyzer, $"0x{address:x}", CancellationToken.None); - Assert.Equal(ReadyToRunQueryOutcome.Resolved, result.Outcome); - Assert.Equal(method.Token, result.Report!.Token); + Assert.AreEqual(ReadyToRunQueryOutcome.Resolved, result.Outcome); + Assert.AreEqual(method.Token, result.Report!.Token); } /// A precompiled method's native disassembly names its indirect call targets via imports. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PrecompiledNative_NamesIndirectCallTargets() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleDll!); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleDll!); // MoveNext calls Console.WriteLine through an import slot; the resolver names it. var result = ReadyToRunCorrelationQuery.Resolve( - analyzer, "MoveNext", TestContext.Current.CancellationToken); + analyzer, "MoveNext", CancellationToken.None); - Assert.Equal(ReadyToRunQueryOutcome.Resolved, result.Outcome); - Assert.NotNull(result.Report!.NativeText); + Assert.AreEqual(ReadyToRunQueryOutcome.Resolved, result.Outcome); + Assert.IsNotNull(result.Report!.NativeText); Assert.Contains("Console.WriteLine", result.Report.NativeText); } /// A method present in metadata but absent from the precompiled map resolves as IL-only. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ByName_NotPrecompiled_ResolvesAsIlOnly() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleDll!); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleDll!); var index = analyzer.ReadyToRunIndex!; // Find a metadata method whose name is unique and not in the precompiled map. @@ -96,13 +103,13 @@ public void ByName_NotPrecompiled_ResolvesAsIlOnly() .Where(g => g.Count() == 1) .Select(g => g.Single()) .FirstOrDefault(); - Assert.SkipWhen(byName is null, "every metadata method in this image is precompiled."); + TestSkip.When(byName is null, "every metadata method in this image is precompiled."); var result = ReadyToRunCorrelationQuery.Resolve( - analyzer, byName!.Name, TestContext.Current.CancellationToken); + analyzer, byName!.Name, CancellationToken.None); - Assert.Equal(ReadyToRunQueryOutcome.Resolved, result.Outcome); - Assert.Equal(ReadyToRunNativeAvailability.NotPrecompiled, result.Report!.Availability); - Assert.Null(result.Report.NativeText); + Assert.AreEqual(ReadyToRunQueryOutcome.Resolved, result.Outcome); + Assert.AreEqual(ReadyToRunNativeAvailability.NotPrecompiled, result.Report!.Availability); + Assert.IsNull(result.Report.NativeText); } } diff --git a/tests/Dotsider.Tests/ReadyToRunMethodMapTests.cs b/tests/Dotsider.Tests/ReadyToRunMethodMapTests.cs index 87a599da..9f35b40c 100644 --- a/tests/Dotsider.Tests/ReadyToRunMethodMapTests.cs +++ b/tests/Dotsider.Tests/ReadyToRunMethodMapTests.cs @@ -10,37 +10,41 @@ namespace Dotsider.Tests; /// not one contiguous slice; the generic samples prove instantiated generics are recovered with a /// rendered instantiation. /// -[Collection("SampleAssemblies")] -public class ReadyToRunMethodMapTests(SampleAssemblyFixture samples) +[TestClass] +public class ReadyToRunMethodMapTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private const string SkipReason = "ReadyToRun crossgen2 publish did not run on this leg."; /// Every precompiled method has ranges and its total size equals the sum of those ranges. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Methods_HaveCodeRanges_AndConsistentSizes() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleDll!); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleDll!); var methods = analyzer.ReadyToRunMethods; - Assert.NotEmpty(methods); + Assert.IsNotEmpty(methods); foreach (var method in methods) { - Assert.NotEmpty(method.CodeRanges); + Assert.IsNotEmpty(method.CodeRanges); // TotalSize is the sum of every range; no range is dropped from the accounting. - Assert.Equal(method.CodeRanges.Sum(r => r.Size), method.TotalSize); - Assert.All(method.CodeRanges, r => Assert.True(r.Size > 0, "a code range must have a positive size")); + Assert.AreEqual(method.CodeRanges.Sum(r => r.Size), method.TotalSize); + TestAssert.All(method.CodeRanges, r => Assert.IsGreaterThan(0, r.Size, "a code range must have a positive size")); // Exactly one hot entry per method. - Assert.Equal(1, method.CodeRanges.Count(r => r.Kind == ReadyToRunCodeRangeKind.HotEntry)); + Assert.ContainsSingle(r => r.Kind == ReadyToRunCodeRangeKind.HotEntry, method.CodeRanges); } } /// The async state machine's MoveNext spans multiple disjoint ranges (hot plus funclet/cold). - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AsyncStateMachine_MoveNext_IsMultiRange() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleDll!); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleDll!); // The awaiting MoveNext carries a funclet and/or cold range beyond its hot entry. var moveNext = analyzer.ReadyToRunMethods @@ -48,53 +52,52 @@ public void AsyncStateMachine_MoveNext_IsMultiRange() .OrderByDescending(m => m.CodeRanges.Count) .FirstOrDefault(); - Assert.NotNull(moveNext); - Assert.True(moveNext!.CodeRanges.Count > 1, - $"MoveNext should span multiple ranges, saw {moveNext.CodeRanges.Count}"); + Assert.IsNotNull(moveNext); + Assert.IsGreaterThan(1, moveNext!.CodeRanges.Count, $"MoveNext should span multiple ranges, saw {moveNext.CodeRanges.Count}"); // Disjoint ranges: no two ranges overlap. var ordered = moveNext.CodeRanges.OrderBy(r => r.VirtualAddress).ToList(); for (var i = 1; i < ordered.Count; i++) - Assert.True( - ordered[i].VirtualAddress >= ordered[i - 1].VirtualAddress + (ulong)ordered[i - 1].Size, - "code ranges must be disjoint"); + Assert.IsGreaterThanOrEqualTo(ordered[i - 1].VirtualAddress + (ulong)ordered[i - 1].Size, ordered[i].VirtualAddress, "code ranges must be disjoint"); } /// Instantiated generics are recovered from the instance table with a rendered instantiation. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void GenericInstantiations_AreRecovered_WithRenderedInstantiation() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleDll!); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleDll!); var generics = analyzer.ReadyToRunMethods.Where(m => m.IsGenericInstantiation).ToList(); - Assert.NotEmpty(generics); + Assert.IsNotEmpty(generics); // The instantiation display carries the concrete args or the canonical shared form. - Assert.Contains(generics, g => g.InstantiationDisplay is { } d - && (d.Contains("int") || d.Contains("__Canon"))); + Assert.Contains(g => g.InstantiationDisplay is { } d + && (d.Contains("int") || d.Contains("__Canon")), generics); // A generic instantiation resolves its declaring type and method name from metadata, not a // bare token — its owning MethodDef token was recovered from the instance signature. - Assert.Contains(generics, g => g.DeclaringType is not null && g.Name is not null && g.Token != 0); + Assert.Contains(g => g.DeclaringType is not null && g.Name is not null && g.Token != 0, generics); } /// The index resolves a method both by its token and by its hot entry address. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Index_FindsMethodByToken_AndByAddress() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleDll!); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleDll!); var index = analyzer.ReadyToRunIndex; - Assert.NotNull(index); + Assert.IsNotNull(index); var sample = analyzer.ReadyToRunMethods.First(m => m.Token != 0 && m.CodeRanges.Count > 0); var byToken = index!.Find(sample.AssemblyName, sample.Token); - Assert.NotNull(byToken); - Assert.Equal(sample.Token, byToken!.Token); + Assert.IsNotNull(byToken); + Assert.AreEqual(sample.Token, byToken!.Token); // The hot entry's own address resolves back to the same method. var hot = sample.CodeRanges.First(r => r.Kind == ReadyToRunCodeRangeKind.HotEntry); var byAddress = index.FindByAddress(hot.VirtualAddress); - Assert.NotNull(byAddress); - Assert.Equal(sample.Token, byAddress!.Token); + Assert.IsNotNull(byAddress); + Assert.AreEqual(sample.Token, byAddress!.Token); } } diff --git a/tests/Dotsider.Tests/ReadyToRunReaderTests.cs b/tests/Dotsider.Tests/ReadyToRunReaderTests.cs index e8961640..165adfa7 100644 --- a/tests/Dotsider.Tests/ReadyToRunReaderTests.cs +++ b/tests/Dotsider.Tests/ReadyToRunReaderTests.cs @@ -8,52 +8,56 @@ namespace Dotsider.Tests; /// against the real Native AOT fixture /// (PE on Windows, ELF on Linux, Mach-O on macOS — each CI runner builds its own format). /// -[Collection("SampleAssemblies")] -public class ReadyToRunReaderTests(SampleAssemblyFixture samples) +[TestClass] +public class ReadyToRunReaderTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// Verifies the section table parses and contains the frozen object region and the /// embedded metadata blob. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadyToRunSections_NativeAotExe_ContainsKnownSections() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var sections = analyzer.ReadyToRunSections; - Assert.NotEmpty(sections); - Assert.Contains(sections, s => s.SectionId == 206); // FrozenObjectRegion - Assert.Contains(sections, s => s.SectionId == 313); // EmbeddedMetadata - Assert.Contains(sections, s => s.SectionId == 201); // GCStaticRegion + Assert.IsNotEmpty(sections); + Assert.Contains(s => s.SectionId == 206, sections); // FrozenObjectRegion + Assert.Contains(s => s.SectionId == 313, sections); // EmbeddedMetadata + Assert.Contains(s => s.SectionId == 201, sections); // GCStaticRegion } /// /// Verifies section ids are sorted ascending and the embedded metadata section is /// file-backed with the NativeFormat signature. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadyToRunSections_NativeAotExe_MetadataSectionIsFileBacked() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var bytes = File.ReadAllBytes(samples.NativeAotConsoleExe!); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + var bytes = File.ReadAllBytes(Samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var sections = analyzer.ReadyToRunSections; var ids = sections.Select(s => s.SectionId).ToList(); - Assert.Equal(ids.OrderBy(i => i), ids); + Assert.AreSequenceEqual([.. ids.OrderBy(i => i)], ids); - var metadata = Assert.Single(sections, s => s.SectionId == 313); - Assert.NotNull(metadata.FileOffset); - Assert.True(metadata.Size > 0); + var metadata = Assert.ContainsSingle(s => s.SectionId == 313, sections); + Assert.IsNotNull(metadata.FileOffset); + Assert.IsGreaterThan(0, metadata.Size); // NativeFormat metadata magic 0xDEADDFFD at the section start. var magic = BitConverter.ToUInt32(bytes, metadata.FileOffset!.Value); - Assert.Equal(0xDEADDFFDu, magic); + Assert.AreEqual(0xDEADDFFDu, magic); } /// @@ -61,34 +65,36 @@ public void ReadyToRunSections_NativeAotExe_MetadataSectionIsFileBacked() /// platform: file-backed on Windows, a NOBITS region the runtime fills at startup on /// Linux (paired with a dehydrated data section that rebuilds it). /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadyToRunSections_FrozenRegion_FileBackingMatchesPlatform() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - using var analyzer = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var analyzer = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); - var frozen = Assert.Single(analyzer.ReadyToRunSections, s => s.SectionId == 206); + var frozen = Assert.ContainsSingle(s => s.SectionId == 206, analyzer.ReadyToRunSections); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { - Assert.NotNull(frozen.FileOffset); + Assert.IsNotNull(frozen.FileOffset); } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { - Assert.Null(frozen.FileOffset); - Assert.Contains(analyzer.ReadyToRunSections, s => s.SectionId == 207); // DehydratedData + Assert.IsNull(frozen.FileOffset); + Assert.Contains(s => s.SectionId == 207, analyzer.ReadyToRunSections); // DehydratedData } } /// /// Verifies a managed assembly has no ReadyToRun sections. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadyToRunSections_ManagedDll_Empty() { - using var analyzer = new AssemblyAnalyzer(samples.RichLibraryDll); + using var analyzer = new AssemblyAnalyzer(Samples.RichLibraryDll); - Assert.Empty(analyzer.ReadyToRunSections); + Assert.IsEmpty(analyzer.ReadyToRunSections); } } diff --git a/tests/Dotsider.Tests/ReadyToRunRoutingTests.cs b/tests/Dotsider.Tests/ReadyToRunRoutingTests.cs index ef985314..26014840 100644 --- a/tests/Dotsider.Tests/ReadyToRunRoutingTests.cs +++ b/tests/Dotsider.Tests/ReadyToRunRoutingTests.cs @@ -8,9 +8,11 @@ namespace Dotsider.Tests; /// An overloaded name lists candidates and exits non-zero rather than guessing; disassembling a /// multi-range method renders every block (its import-named call targets appear), not just the hot one. /// -[Collection("SampleAssemblies")] -public class ReadyToRunRoutingTests(SampleAssemblyFixture samples) +[TestClass] +public class ReadyToRunRoutingTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private const string SkipReason = "ReadyToRun crossgen2 publish did not run on this leg."; private static readonly string s_projectPath = Path.Combine( @@ -19,61 +21,66 @@ public class ReadyToRunRoutingTests(SampleAssemblyFixture samples) private static readonly string s_buildConfig = DetectBuildConfig(); /// Bare --r2r-correlate prints the ReadyToRun stats over the image. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task R2rCorrelate_Bare_PrintsStats() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); - var (exit, stdout, _) = await RunDotsiderAsync("analyze", samples.ReadyToRunConsoleDll!, "--r2r-correlate"); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); + var (exit, stdout, _) = await RunDotsiderAsync("analyze", Samples.ReadyToRunConsoleDll!, "--r2r-correlate"); - Assert.Equal(0, exit); + Assert.AreEqual(0, exit); Assert.Contains("ReadyToRun", stdout); Assert.Contains("Precompiled", stdout); } /// A unique method resolves through the CLI to its native and IL. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task R2rCorrelate_UniqueName_ResolvesMethod() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); var (exit, stdout, _) = await RunDotsiderAsync( - "analyze", samples.ReadyToRunConsoleDll!, "--r2r-correlate", "Greeter.get_Name"); + "analyze", Samples.ReadyToRunConsoleDll!, "--r2r-correlate", "Greeter.get_Name"); - Assert.Equal(0, exit); + Assert.AreEqual(0, exit); Assert.Contains("get_Name", stdout); } /// An overloaded name lists candidates and exits non-zero rather than picking first. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task R2rCorrelate_Overloaded_ListsCandidatesAndFails() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); var (exit, stdout, stderr) = await RunDotsiderAsync( - "analyze", samples.ReadyToRunConsoleDll!, "--r2r-correlate", "Greet"); + "analyze", Samples.ReadyToRunConsoleDll!, "--r2r-correlate", "Greet"); - Assert.NotEqual(0, exit); + Assert.AreNotEqual(0, exit); Assert.Contains("ambiguous", (stdout + stderr).ToLowerInvariant()); } /// The symbol table lists the ReadyToRun-derived symbols. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Symbols_ListsReadyToRunSymbols() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); - var (exit, stdout, _) = await RunDotsiderAsync("analyze", samples.ReadyToRunConsoleDll!, "--symbols"); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); + var (exit, stdout, _) = await RunDotsiderAsync("analyze", Samples.ReadyToRunConsoleDll!, "--symbols"); - Assert.Equal(0, exit); + Assert.AreEqual(0, exit); Assert.Contains("Greeter", stdout); } /// Disassembling a multi-range method renders all blocks with import-named call targets. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Disasm_MultiRangeMethod_RendersAllBlocks() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); var (exit, stdout, _) = await RunDotsiderAsync( - "analyze", samples.ReadyToRunConsoleDll!, "--disasm", "MoveNext"); + "analyze", Samples.ReadyToRunConsoleDll!, "--disasm", "MoveNext"); - Assert.Equal(0, exit); + Assert.AreEqual(0, exit); // The import resolver names the indirect call; the multi-range body shows a funclet/cold block. Assert.Contains("Console.WriteLine", stdout); } @@ -99,11 +106,12 @@ private static string DetectBuildConfig() RedirectStandardOutput = true, RedirectStandardError = true, }; + TestProcessEnvironment.RemoveCodeCoverageVariables(psi); var process = Process.Start(psi)!; - var stdout = await process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken); - var stderr = await process.StandardError.ReadToEndAsync(TestContext.Current.CancellationToken); - await process.WaitForExitAsync(TestContext.Current.CancellationToken); + var stdout = await process.StandardOutput.ReadToEndAsync(CancellationToken.None); + var stderr = await process.StandardError.ReadToEndAsync(CancellationToken.None); + await process.WaitForExitAsync(CancellationToken.None); return (process.ExitCode, stdout, stderr); } diff --git a/tests/Dotsider.Tests/ReadyToRunSignatureWalkerTests.cs b/tests/Dotsider.Tests/ReadyToRunSignatureWalkerTests.cs new file mode 100644 index 00000000..42397499 --- /dev/null +++ b/tests/Dotsider.Tests/ReadyToRunSignatureWalkerTests.cs @@ -0,0 +1,138 @@ +using Dotsider.Core.Analysis.ReadyToRun; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.Metadata.Ecma335; +using System.Reflection.PortableExecutable; + +namespace Dotsider.Tests; + +/// +/// Synthetic ReadyToRun signature walker regressions for metadata-scope transitions. +/// +[TestClass] +public class ReadyToRunSignatureWalkerTests +{ + /// + /// Verifies a module-wrapped generic-instantiation type decodes its generic type in the referenced + /// module while decoding type arguments in the outer signature scope, matching the runtime reader. + /// + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + public void ModuleZapsig_GenericInstantiation_ParsesArgumentsInOuterMetadataScope() + { + using var outer = new MetadataScope(BuildAssembly("Outer", "CurrentGeneric", "OuterArg")); + using var module = new MetadataScope(BuildAssembly("Module", "ExternalGeneric", "WrongArg")); + + byte[] signature = + [ + 0x04, // READYTORUN_METHOD_SIG_MethodInstantiation + 0x01, // MethodDef rid 1 + 0x01, // one method-instantiation argument + 0x3f, 0x02, // MODULE_ZAPSIG module 2 + 0x15, // GENERICINST + 0x12, 0x08, // CLASS TypeDef rid 2 => ExternalGeneric in module scope + 0x01, // one generic type argument + 0x12, 0x0c // CLASS TypeDef rid 3 => OuterArg in outer scope + ]; + + var sig = ReadyToRunSignatureWalker.ParseMethod( + new R2RNativeReader(signature), 0, outer.Reader, i => i == 2 ? module.Reader : null); + + Assert.AreEqual(signature.Length, sig.Offset); + Assert.AreEqual(0x0600_0001, sig.MethodToken); + Assert.IsTrue(sig.CrossModule); + Assert.AreEqual(2, sig.ModuleIndex); + Assert.AreEqual(">", sig.InstantiationDisplay); + } + + /// + /// Verifies unresolved module-zapsig types are not resolved against the outer metadata by accident. + /// + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + public void ModuleZapsig_UnresolvedModule_DoesNotResolveAgainstOuterMetadata() + { + using var outer = new MetadataScope(BuildAssembly("Outer", "WrongType")); + + byte[] signature = + [ + 0x04, // READYTORUN_METHOD_SIG_MethodInstantiation + 0x01, // MethodDef rid 1 + 0x01, // one method-instantiation argument + 0x3f, 0x07, // MODULE_ZAPSIG module 7, intentionally unresolved + 0x12, 0x08 // CLASS TypeDef rid 2; this is WrongType in the outer metadata + ]; + + var sig = ReadyToRunSignatureWalker.ParseMethod(new R2RNativeReader(signature), 0, outer.Reader); + + Assert.AreEqual("", sig.InstantiationDisplay); + Assert.AreEqual(7, sig.ModuleIndex); + Assert.IsTrue(sig.CrossModule); + } + + private static byte[] BuildAssembly(string assemblyName, params string[] typeNames) + { + var metadata = new MetadataBuilder(); + metadata.AddAssembly( + metadata.GetOrAddString(assemblyName), + new Version(1, 0, 0, 0), + default, + default, + 0, + AssemblyHashAlgorithm.None); + metadata.AddModule( + 0, + metadata.GetOrAddString(assemblyName + ".dll"), + metadata.GetOrAddGuid(Guid.NewGuid()), + default, + default); + metadata.AddTypeDefinition( + default, + default, + metadata.GetOrAddString(""), + baseType: default, + fieldList: MetadataTokens.FieldDefinitionHandle(1), + methodList: MetadataTokens.MethodDefinitionHandle(1)); + + foreach (var typeName in typeNames) + { + metadata.AddTypeDefinition( + TypeAttributes.Public, + metadata.GetOrAddString("Synthetic"), + metadata.GetOrAddString(typeName), + baseType: default, + fieldList: MetadataTokens.FieldDefinitionHandle(1), + methodList: MetadataTokens.MethodDefinitionHandle(1)); + } + + var pe = new ManagedPEBuilder( + new PEHeaderBuilder(imageCharacteristics: Characteristics.Dll), + new MetadataRootBuilder(metadata), + ilStream: new BlobBuilder()); + var blob = new BlobBuilder(); + pe.Serialize(blob); + return blob.ToArray(); + } + + private sealed class MetadataScope : IDisposable + { + private readonly MemoryStream _stream; + private readonly PEReader _reader; + + public MetadataScope(byte[] assemblyBytes) + { + _stream = new MemoryStream(assemblyBytes); + _reader = new PEReader(_stream); + Reader = _reader.GetMetadataReader(); + } + + public MetadataReader Reader { get; } + + public void Dispose() + { + _reader.Dispose(); + _stream.Dispose(); + GC.SuppressFinalize(this); + } + } +} diff --git a/tests/Dotsider.Tests/ReadyToRunSurfaceTests.cs b/tests/Dotsider.Tests/ReadyToRunSurfaceTests.cs index 37284d96..ab0cdb0b 100644 --- a/tests/Dotsider.Tests/ReadyToRunSurfaceTests.cs +++ b/tests/Dotsider.Tests/ReadyToRunSurfaceTests.cs @@ -9,52 +9,56 @@ namespace Dotsider.Tests; /// lists the crossgen2 sections, and the Size Map attributes the precompiled native code by method /// rather than IL bytes. /// -[Collection("SampleAssemblies")] -public class ReadyToRunSurfaceTests(SampleAssemblyFixture samples) +[TestClass] +public class ReadyToRunSurfaceTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private const string SkipReason = "ReadyToRun crossgen2 publish did not run on this leg."; /// The COR header's managed-native-header directory points at the R2R header. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ClrHeader_ManagedNativeHeader_IsPopulated() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleDll!); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleDll!); - Assert.NotNull(analyzer.ClrHeader); - Assert.True(analyzer.ClrHeader!.ManagedNativeHeader.Size > 0, - "an R2R image points its managed-native-header at the READYTORUN_HEADER"); + Assert.IsNotNull(analyzer.ClrHeader); + Assert.IsGreaterThan(0, analyzer.ClrHeader!.ManagedNativeHeader.Size, "an R2R image points its managed-native-header at the READYTORUN_HEADER"); } /// The PE section table lists the crossgen2 sections (RuntimeFunctions, MethodDefEntryPoints). - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadyToRunSections_ListCrossgenSections() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleDll!); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleDll!); var sections = analyzer.ReadyToRunSections; - Assert.NotEmpty(sections); + Assert.IsNotEmpty(sections); // RuntimeFunctions = 102 and MethodDefEntryPoints = 103 are always present in a normal image. - Assert.Contains(sections, s => s.SectionId == 102); - Assert.Contains(sections, s => s.SectionId == 103); + Assert.Contains(s => s.SectionId == 102, sections); + Assert.Contains(s => s.SectionId == 103, sections); } /// The Size Map sizes the precompiled native code by method, not IL bytes. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SizeMap_AttributesPrecompiledNativeCode() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, SkipReason); - using var analyzer = new AssemblyAnalyzer(samples.ReadyToRunConsoleDll!); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, SkipReason); + using var analyzer = new AssemblyAnalyzer(Samples.ReadyToRunConsoleDll!); var tree = SizeAnalyzer.BuildSizeTree(analyzer); - Assert.True(tree.Size > 0, "the size tree should carry the precompiled native bytes"); + Assert.IsGreaterThan(0, tree.Size, "the size tree should carry the precompiled native bytes"); // The tree attributes native code down to the method: a known type appears as a node whose // size accounts for its precompiled methods (never zero for a type with a native body). var greeter = FindNode(tree, n => n.Name.Contains("Greeter")); - Assert.NotNull(greeter); - Assert.True(greeter!.Size > 0); + Assert.IsNotNull(greeter); + Assert.IsGreaterThan(0, greeter!.Size); } private static SizeNode? FindNode(SizeNode node, Func predicate) diff --git a/tests/Dotsider.Tests/ReleaseWorkflowTests.cs b/tests/Dotsider.Tests/ReleaseWorkflowTests.cs index 3c349d8a..d7e88b42 100644 --- a/tests/Dotsider.Tests/ReleaseWorkflowTests.cs +++ b/tests/Dotsider.Tests/ReleaseWorkflowTests.cs @@ -3,13 +3,15 @@ namespace Dotsider.Tests; /// /// Regression tests for release workflow behavior that is easy to break in YAML. /// +[TestClass] public class ReleaseWorkflowTests { /// /// Verifies generated winget branch commits suppress fork-side push workflows without /// leaking the marker into user-facing winget pull request titles. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void WingetSubmissionCommits_SkipForkPushWorkflowsOnly() { var releaseWorkflow = File.ReadAllText(Path.Combine( @@ -20,7 +22,7 @@ public void WingetSubmissionCommits_SkipForkPushWorkflowsOnly() Assert.Contains("message=\"Update willibrandon.dotsider to $version [skip actions]\"", releaseWorkflow); Assert.Contains("message=\"Update willibrandon.dotsider-mcp to $version [skip actions]\"", releaseWorkflow); - Assert.Equal(2, CountOccurrences(releaseWorkflow, "[skip actions]")); + Assert.AreEqual(2, CountOccurrences(releaseWorkflow, "[skip actions]")); Assert.DoesNotContain("[skip ci]", releaseWorkflow); Assert.Contains("$prTitle = \"Update willibrandon.dotsider to $version\"", releaseWorkflow); diff --git a/tests/Dotsider.Tests/RootCauseInvestigation.cs b/tests/Dotsider.Tests/RootCauseInvestigation.cs index f3e01d09..0654104e 100644 --- a/tests/Dotsider.Tests/RootCauseInvestigation.cs +++ b/tests/Dotsider.Tests/RootCauseInvestigation.cs @@ -8,13 +8,15 @@ namespace Dotsider.Tests; /// /// Regression tests for the ldelema operand table bug that caused IL walk desync. /// +[TestClass] public class IlWalkRegressionTests { /// /// Zero-fallback invariant: walking all token-bearing operands in CoreLib must produce /// zero resolver failures. Any failure indicates an operand table bug causing offset desync. /// - [Fact(Timeout = 120_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void CoreLib_AllTokenOperands_ResolveWithoutFallback() { var runtimeDir = RuntimeEnvironment.GetRuntimeDirectory(); @@ -143,7 +145,7 @@ IlDisassembler.OperandKind.InlineI8 or } } - Assert.True(failures.Count == 0, + Assert.IsEmpty(failures, $"Found {failures.Count} resolver fallbacks in {tokensChecked} tokens " + $"(first 5):\n" + string.Join("\n", failures.Take(5))); } @@ -152,10 +154,10 @@ IlDisassembler.OperandKind.InlineI8 or /// Regression test: ldelema must be decoded with InlineType (4-byte operand). /// Before the fix, it mapped to None (0 bytes), causing a 4-byte offset desync. /// - [Fact] + [TestMethod] public void GetOperandType_Ldelema_ReturnsInlineType() { - Assert.Equal( + Assert.AreEqual( IlDisassembler.OperandKind.InlineType, IlDisassembler.GetOperandType(ILOpCode.Ldelema)); } @@ -165,7 +167,8 @@ public void GetOperandType_Ldelema_ReturnsInlineType() /// the BadImageFormatException fallback in ResolveTokenForComparison. /// The original crash was a desync from the missing ldelema operand entry. /// - [Fact(Timeout = 120_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void CoreLib_DistinctAnalyzerDiff_NoResolverFallbacks() { var runtimeDir = RuntimeEnvironment.GetRuntimeDirectory(); @@ -177,7 +180,7 @@ public void CoreLib_DistinctAnalyzerDiff_NoResolverFallbacks() var result = AssemblyDiffer.Compare(left, right); // With correct operand table, identity diff should have zero body changes - Assert.DoesNotContain(result.MethodDiffs, d => - d.ChangeDescription?.Contains("body") == true); + Assert.DoesNotContain(d => + d.ChangeDescription?.Contains("body") == true, result.MethodDiffs); } } diff --git a/tests/Dotsider.Tests/RuntimeTracerTests.cs b/tests/Dotsider.Tests/RuntimeTracerTests.cs index 557a168b..8be16b48 100644 --- a/tests/Dotsider.Tests/RuntimeTracerTests.cs +++ b/tests/Dotsider.Tests/RuntimeTracerTests.cs @@ -9,9 +9,11 @@ namespace Dotsider.Tests; /// /// Tests for Runtime Tracer. /// -[Collection("SampleAssemblies")] -public class RuntimeTracerTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class RuntimeTracerTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private RuntimeTracer? _tracer; private Hex1bApp? _app; private Hex1bAppWorkloadAdapter? _workload; @@ -36,7 +38,7 @@ private RuntimeTracer CreateTracer(string assemblyPath, string args = "") /// Waits for the tracer to reach Exited or Error. If the process hangs /// under EventPipe (known issue on Windows CI), stops it after the timeout. /// The caller's timeout plus the 10-second recovery wait must fit inside - /// the test's [Fact(Timeout)], or xUnit aborts the test before this + /// the test's [Timeout], or MSTest aborts the test before this /// recovery ever runs. /// private static async Task WaitForExitAsync(RuntimeTracer tracer, TimeSpan timeout) @@ -59,176 +61,187 @@ await TestHelpers.WaitUntilAsync( /// /// Verifies launch hello world transitions to running. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task LaunchHelloWorld_TransitionsToRunning() { - var tracer = CreateTracer(samples.HelloWorldDll); - Assert.Equal(TraceProcessState.Idle, tracer.ProcessState); + var tracer = CreateTracer(Samples.HelloWorldDll); + Assert.AreEqual(TraceProcessState.Idle, tracer.ProcessState); tracer.Start(); await TestHelpers.WaitUntilAsync( () => tracer.ProcessState is TraceProcessState.Running or TraceProcessState.Exited or TraceProcessState.Error, TimeSpan.FromSeconds(30)); - Assert.True(tracer.ProcessState is TraceProcessState.Running or TraceProcessState.Exited, + Assert.IsTrue(tracer.ProcessState is TraceProcessState.Running or TraceProcessState.Exited, $"Expected Running or Exited but got {tracer.ProcessState}: {tracer.ErrorMessage}"); } /// /// Verifies launch hello world exits successfully. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task LaunchHelloWorld_ExitsSuccessfully() { - var tracer = CreateTracer(samples.HelloWorldDll); + var tracer = CreateTracer(Samples.HelloWorldDll); tracer.Start(); await WaitForExitAsync(tracer, TimeSpan.FromSeconds(15)); - Assert.Equal(TraceProcessState.Exited, tracer.ProcessState); + Assert.AreEqual(TraceProcessState.Exited, tracer.ProcessState); // ExitCode is set by the Process.Exited handler which fires asynchronously — // wait for it rather than reading immediately after state transition. await TestHelpers.WaitUntilAsync(() => tracer.ExitCode is not null, TimeSpan.FromSeconds(5)); - Assert.Equal(0, tracer.ExitCode); + Assert.AreEqual(0, tracer.ExitCode); } /// /// Verifies launch hello world captures events. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task LaunchHelloWorld_CapturesEvents() { - var tracer = CreateTracer(samples.HelloWorldDll); + var tracer = CreateTracer(Samples.HelloWorldDll); tracer.Start(); await WaitForExitAsync(tracer, TimeSpan.FromSeconds(15)); - Assert.Equal(TraceProcessState.Exited, tracer.ProcessState); + Assert.AreEqual(TraceProcessState.Exited, tracer.ProcessState); var events = tracer.GetEvents(); - Assert.NotEmpty(events); + Assert.IsNotEmpty(events); } /// /// Verifies launch hello world captures counters. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task LaunchHelloWorld_CapturesCounters() { - var tracer = CreateTracer(samples.HelloWorldDll); + var tracer = CreateTracer(Samples.HelloWorldDll); tracer.Start(); // Counters arrive every ~1s — wait up to 10s await TestHelpers.WaitUntilAsync( () => tracer.GetLatestCounters() != null || tracer.ProcessState == TraceProcessState.Error, TimeSpan.FromSeconds(30)); - Assert.NotEqual(TraceProcessState.Error, tracer.ProcessState); + Assert.AreNotEqual(TraceProcessState.Error, tracer.ProcessState); var counters = tracer.GetLatestCounters(); - Assert.NotNull(counters); + Assert.IsNotNull(counters); } /// /// Verifies launch hello world captures output. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task LaunchHelloWorld_CapturesOutput() { - var tracer = CreateTracer(samples.HelloWorldDll); + var tracer = CreateTracer(Samples.HelloWorldDll); tracer.Start(); await WaitForExitAsync(tracer, TimeSpan.FromSeconds(15)); - Assert.Equal(TraceProcessState.Exited, tracer.ProcessState); + Assert.AreEqual(TraceProcessState.Exited, tracer.ProcessState); var output = tracer.GetOutput(); - Assert.NotEmpty(output); + Assert.IsNotEmpty(output); } /// /// Verifies launch hello world summary has events. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task LaunchHelloWorld_SummaryHasEvents() { - var tracer = CreateTracer(samples.HelloWorldDll); + var tracer = CreateTracer(Samples.HelloWorldDll); tracer.Start(); await WaitForExitAsync(tracer, TimeSpan.FromSeconds(15)); - Assert.Equal(TraceProcessState.Exited, tracer.ProcessState); + Assert.AreEqual(TraceProcessState.Exited, tracer.ProcessState); var summary = tracer.GetSummary(); - Assert.True(summary.TotalEvents > 0); + Assert.IsGreaterThan(0, summary.TotalEvents); } /// /// Verifies launch hello world process id is set. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task LaunchHelloWorld_ProcessIdIsSet() { - var tracer = CreateTracer(samples.HelloWorldDll); + var tracer = CreateTracer(Samples.HelloWorldDll); tracer.Start(); await TestHelpers.WaitUntilAsync( () => tracer.ProcessId != null, TimeSpan.FromSeconds(10)); - Assert.NotNull(tracer.ProcessId); + Assert.IsNotNull(tracer.ProcessId); } /// /// Verifies launch hello world elapsed increases. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task LaunchHelloWorld_ElapsedIncreases() { - var tracer = CreateTracer(samples.HelloWorldDll); + var tracer = CreateTracer(Samples.HelloWorldDll); tracer.Start(); await TestHelpers.WaitUntilAsync( () => tracer.ProcessState is TraceProcessState.Running or TraceProcessState.Exited or TraceProcessState.Error, TimeSpan.FromSeconds(30)); - Assert.NotEqual(TraceProcessState.Error, tracer.ProcessState); + Assert.AreNotEqual(TraceProcessState.Error, tracer.ProcessState); var elapsed1 = tracer.Elapsed; if (tracer.ProcessState == TraceProcessState.Running) { - await Task.Delay(500, TestContext.Current.CancellationToken); - Assert.True(tracer.Elapsed > elapsed1); + await Task.Delay(500, CancellationToken.None); + Assert.IsGreaterThan(elapsed1, tracer.Elapsed); } } /// /// Verifies error message null on successful run. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ErrorMessage_NullOnSuccessfulRun() { - var tracer = CreateTracer(samples.HelloWorldDll); + var tracer = CreateTracer(Samples.HelloWorldDll); tracer.Start(); await WaitForExitAsync(tracer, TimeSpan.FromSeconds(15)); - Assert.Equal(TraceProcessState.Exited, tracer.ProcessState); - Assert.Null(tracer.ErrorMessage); + Assert.AreEqual(TraceProcessState.Exited, tracer.ProcessState); + Assert.IsNull(tracer.ErrorMessage); } /// /// Verifies summary total exceptions matches event count. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Summary_TotalExceptions_MatchesEventCount() { - var tracer = CreateTracer(samples.HelloWorldDll); + var tracer = CreateTracer(Samples.HelloWorldDll); tracer.Start(); await WaitForExitAsync(tracer, TimeSpan.FromSeconds(15)); - Assert.Equal(TraceProcessState.Exited, tracer.ProcessState); + Assert.AreEqual(TraceProcessState.Exited, tracer.ProcessState); var summary = tracer.GetSummary(); var exceptionEvents = summary.EventsByCategory .GetValueOrDefault(TraceEventCategory.Exception); - Assert.Equal(exceptionEvents, summary.TotalExceptions); + Assert.AreEqual(exceptionEvents, summary.TotalExceptions); } /// /// Verifies complex app short lived still captures events. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ComplexApp_ShortLived_StillCapturesEvents() { - var tracer = CreateTracer(samples.ComplexAppDll); + var tracer = CreateTracer(Samples.ComplexAppDll); tracer.Start(); await WaitForExitAsync(tracer, TimeSpan.FromSeconds(45)); - Assert.Equal(TraceProcessState.Exited, tracer.ProcessState); + Assert.AreEqual(TraceProcessState.Exited, tracer.ProcessState); var events = tracer.GetEvents(); await TestHelpers.WaitUntilAsync(() => tracer.ExitCode is not null, TimeSpan.FromSeconds(5)); - Assert.Equal(0, tracer.ExitCode); - Assert.NotEmpty(events); + Assert.AreEqual(0, tracer.ExitCode); + Assert.IsNotEmpty(events); } // --- Lifecycle edge cases --- @@ -236,10 +249,11 @@ public async Task ComplexApp_ShortLived_StillCapturesEvents() /// /// Verifies stop during startup unblocks within five seconds. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Stop_DuringStartup_UnblocksWithinFiveSeconds() { - var tracer = CreateTracer(samples.HelloWorldDll); + var tracer = CreateTracer(Samples.HelloWorldDll); tracer.Start(); tracer.Stop(); await TestHelpers.WaitUntilAsync( @@ -250,10 +264,11 @@ await TestHelpers.WaitUntilAsync( /// /// Verifies dispose while running cleans up. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Dispose_WhileRunning_CleansUp() { - var tracer = CreateTracer(samples.HelloWorldDll); + var tracer = CreateTracer(Samples.HelloWorldDll); tracer.Start(); await TestHelpers.WaitUntilAsync( () => tracer.ProcessState is TraceProcessState.Running or TraceProcessState.Exited @@ -266,10 +281,11 @@ await TestHelpers.WaitUntilAsync( /// /// Verifies dispose called twice no throw. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Dispose_CalledTwice_NoThrow() { - var tracer = CreateTracer(samples.HelloWorldDll); + var tracer = CreateTracer(Samples.HelloWorldDll); tracer.Dispose(); tracer.Dispose(); // should not throw _tracer = null; @@ -278,31 +294,33 @@ public void Dispose_CalledTwice_NoThrow() /// /// Verifies event categories contain expected types. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task EventCategories_ContainExpectedTypes() { - var tracer = CreateTracer(samples.HelloWorldDll); + var tracer = CreateTracer(Samples.HelloWorldDll); tracer.Start(); await WaitForExitAsync(tracer, TimeSpan.FromSeconds(15)); - Assert.Equal(TraceProcessState.Exited, tracer.ProcessState); + Assert.AreEqual(TraceProcessState.Exited, tracer.ProcessState); var events = tracer.GetEvents(); var categories = events.Select(e => e.Category).Distinct().ToHashSet(); // HelloWorld triggers GC and JIT at minimum - Assert.True(categories.Count > 0); + Assert.IsGreaterThan(0, categories.Count); } /// /// Verifies jit events overloaded methods disambiguated by token. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task JitEvents_OverloadedMethods_DisambiguatedByToken() { - var tracer = CreateTracer(samples.HelloWorldDll); + var tracer = CreateTracer(Samples.HelloWorldDll); tracer.Start(); // Wait for the specific Format JIT events — ProcessState can transition // to Exited before all events are flushed from the EventPipe buffer. await WaitForExitAsync(tracer, TimeSpan.FromSeconds(15)); - Assert.Equal(TraceProcessState.Exited, tracer.ProcessState); + Assert.AreEqual(TraceProcessState.Exited, tracer.ProcessState); await TestHelpers.WaitUntilAsync( () => tracer.GetEvents().Count(e => e.Category == TraceEventCategory.JIT && e.Detail.EndsWith(".Format") && e.MetadataToken > 0) >= 2, @@ -311,42 +329,39 @@ await TestHelpers.WaitUntilAsync( var jitEvents = tracer.GetEvents() .Where(e => e.Category == TraceEventCategory.JIT) .ToList(); - Assert.NotEmpty(jitEvents); + Assert.IsNotEmpty(jitEvents); var formatEvents = jitEvents .Where(e => e.Detail.EndsWith(".Format")) .ToList(); - Assert.True(formatEvents.Count >= 2, - $"Expected >=2 Formatter.Format JIT events, got {formatEvents.Count}"); + Assert.IsGreaterThanOrEqualTo(2, formatEvents.Count, $"Expected >=2 Formatter.Format JIT events, got {formatEvents.Count}"); // Tokens must be distinct (the whole point of disambiguation) var distinctTokens = formatEvents.Select(e => e.MetadataToken).Distinct().ToList(); - Assert.True(distinctTokens.Count >= 2, - $"Overloaded JIT events should have distinct tokens, got: " + + Assert.IsGreaterThanOrEqualTo(2, distinctTokens.Count, $"Overloaded JIT events should have distinct tokens, got: " + $"{string.Join(", ", formatEvents.Select(e => $"0x{e.MetadataToken:X8}"))}"); // Verify token-based lookup resolves each to a different MethodDefInfo, // while name-based lookup would collapse them to the same method. - using var analyzer = new AssemblyAnalyzer(samples.HelloWorldDll); + using var analyzer = new AssemblyAnalyzer(Samples.HelloWorldDll); var evt1 = formatEvents[0]; var evt2 = formatEvents.First(e => e.MetadataToken != evt1.MetadataToken); var byToken1 = analyzer.MethodDefs.FirstOrDefault(m => m.Token == evt1.MetadataToken); var byToken2 = analyzer.MethodDefs.FirstOrDefault(m => m.Token == evt2.MetadataToken); - Assert.NotNull(byToken1); - Assert.NotNull(byToken2); - Assert.NotEqual(byToken1.Token, byToken2.Token); + Assert.IsNotNull(byToken1); + Assert.IsNotNull(byToken2); + Assert.AreNotEqual(byToken1.Token, byToken2.Token); // Name-based lookup returns the same method for both (the disambiguation gap) - Assert.True(DynamicAnalysisView.TryParseJitDetail(evt1.Detail, out var declType, out var methName)); + Assert.IsTrue(DynamicAnalysisView.TryParseJitDetail(evt1.Detail, out var declType, out var methName)); var byName = analyzer.MethodDefs .Where(m => m.DeclaringType == declType && m.Name == methName) .ToList(); - Assert.True(byName.Count >= 2, - "Analyzer should have >=2 MethodDefs with the same DeclaringType+Name"); + Assert.IsGreaterThanOrEqualTo(2, byName.Count, "Analyzer should have >=2 MethodDefs with the same DeclaringType+Name"); var firstByName = byName[0]; // Without token, FirstOrDefault always returns the same method regardless of which event - Assert.Equal(firstByName, analyzer.MethodDefs.FirstOrDefault( + Assert.AreEqual(firstByName, analyzer.MethodDefs.FirstOrDefault( m => m.DeclaringType == declType && m.Name == methName)); } @@ -355,10 +370,10 @@ await TestHelpers.WaitUntilAsync( /// public void Dispose() { + GC.SuppressFinalize(this); _tracer?.Dispose(); _app?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/SampleAssemblyCollection.cs b/tests/Dotsider.Tests/SampleAssemblyCollection.cs deleted file mode 100644 index dbef95a0..00000000 --- a/tests/Dotsider.Tests/SampleAssemblyCollection.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Dotsider.Tests; - -/// -/// xUnit collection binding test classes to the shared . -/// -[CollectionDefinition("SampleAssemblies")] -public class SampleAssemblyCollection : ICollectionFixture; diff --git a/tests/Dotsider.Tests/SampleAssemblyFixture.cs b/tests/Dotsider.Tests/SampleAssemblyFixture.cs index cbf64fc7..562e45db 100644 --- a/tests/Dotsider.Tests/SampleAssemblyFixture.cs +++ b/tests/Dotsider.Tests/SampleAssemblyFixture.cs @@ -5,9 +5,9 @@ namespace Dotsider.Tests; /// -/// xUnit fixture that builds and exposes paths to all sample assemblies shared across tests. +/// MSTest fixture that builds and exposes paths to all sample assemblies shared across tests. /// -public class SampleAssemblyFixture : IAsyncLifetime +internal class SampleAssemblyFixture { private string _repoRoot = null!; @@ -529,47 +529,47 @@ public async ValueTask InitializeAsync() File.WriteAllBytes(NonDotNetBinaryPath, [0xDE, 0xAD, 0xBE, 0xEF]); // Verify critical paths exist - Assert.True(File.Exists(HelloWorldDll), $"HelloWorld.dll not found at {HelloWorldDll}"); - Assert.True(File.Exists(RichLibraryDll), $"RichLibrary.dll not found at {RichLibraryDll}"); - Assert.True(File.Exists(EmbeddedSourceLibDll), + Assert.IsTrue(File.Exists(HelloWorldDll), $"HelloWorld.dll not found at {HelloWorldDll}"); + Assert.IsTrue(File.Exists(RichLibraryDll), $"RichLibrary.dll not found at {RichLibraryDll}"); + Assert.IsTrue(File.Exists(EmbeddedSourceLibDll), $"EmbeddedSourceLib.dll not found at {EmbeddedSourceLibDll}"); - Assert.True(File.Exists(RichLibraryNupkg), $"RichLibrary.nupkg not found at {RichLibraryNupkg}"); - Assert.True(File.Exists(AppLocalRollForwardDll), + Assert.IsTrue(File.Exists(RichLibraryNupkg), $"RichLibrary.nupkg not found at {RichLibraryNupkg}"); + Assert.IsTrue(File.Exists(AppLocalRollForwardDll), $"AppLocalRollForward.dll not found at {AppLocalRollForwardDll}"); var rollForwardBin = Path.GetDirectoryName(AppLocalRollForwardDll)!; - Assert.True(File.Exists(Path.Combine(rollForwardBin, "Microsoft.Diagnostics.NETCore.Client.dll")), + Assert.IsTrue(File.Exists(Path.Combine(rollForwardBin, "Microsoft.Diagnostics.NETCore.Client.dll")), "AppLocalRollForward must deploy NETCore.Client.dll app-local for the roll-forward probe"); - Assert.True(File.Exists(Path.Combine(rollForwardBin, "Microsoft.Diagnostics.Tracing.TraceEvent.dll")), + Assert.IsTrue(File.Exists(Path.Combine(rollForwardBin, "Microsoft.Diagnostics.Tracing.TraceEvent.dll")), "AppLocalRollForward must deploy TraceEvent.dll app-local for its stale AssemblyRef to drive the test"); if (NetFxConsoleExe is not null) - Assert.True(File.Exists(NetFxConsoleExe), $"NetFxConsole.exe not found at {NetFxConsoleExe}"); + Assert.IsTrue(File.Exists(NetFxConsoleExe), $"NetFxConsole.exe not found at {NetFxConsoleExe}"); if (NetFxBindingRedirectsExe is not null) { - Assert.True(File.Exists(NetFxBindingRedirectsExe), + Assert.IsTrue(File.Exists(NetFxBindingRedirectsExe), $"NetFxBindingRedirects.exe not found at {NetFxBindingRedirectsExe}"); var binDir = Path.GetDirectoryName(NetFxBindingRedirectsExe)!; - Assert.True(File.Exists(Path.Combine(binDir, "NetFxBindingRedirects.exe.config")), + Assert.IsTrue(File.Exists(Path.Combine(binDir, "NetFxBindingRedirects.exe.config")), "NetFxBindingRedirects.exe.config missing — app.config did not deploy"); - Assert.True(Directory.Exists(Path.Combine(binDir, "lib")), + Assert.IsTrue(Directory.Exists(Path.Combine(binDir, "lib")), "lib/ subdir missing — privatePath helper did not deploy"); - Assert.True(Directory.Exists(Path.Combine(binDir, "external")), + Assert.IsTrue(Directory.Exists(Path.Combine(binDir, "external")), "external/ subdir missing — codeBase helper did not deploy"); - Assert.True(Directory.Exists(Path.Combine(binDir, "fr")), + Assert.IsTrue(Directory.Exists(Path.Combine(binDir, "fr")), "fr/ subdir missing — culture satellite did not deploy"); - Assert.NotNull(NetFxBindingRedirectsOracle); + Assert.IsNotNull(NetFxBindingRedirectsOracle); } if (NetFxBindingRedirectsClr2Exe is not null) { - Assert.True(File.Exists(NetFxBindingRedirectsClr2Exe), + Assert.IsTrue(File.Exists(NetFxBindingRedirectsClr2Exe), $"NetFxBindingRedirects.Clr2.exe not found at {NetFxBindingRedirectsClr2Exe}"); var binDir = Path.GetDirectoryName(NetFxBindingRedirectsClr2Exe)!; - Assert.True(File.Exists(Path.Combine(binDir, "NetFxBindingRedirects.Clr2.exe.config")), + Assert.IsTrue(File.Exists(Path.Combine(binDir, "NetFxBindingRedirects.Clr2.exe.config")), "NetFxBindingRedirects.Clr2.exe.config missing — app.config did not deploy"); - Assert.True(Directory.Exists(Path.Combine(binDir, "lib")), + Assert.IsTrue(Directory.Exists(Path.Combine(binDir, "lib")), "lib/ subdir missing — Clr2 privatePath helper did not deploy"); - Assert.True(Directory.Exists(Path.Combine(binDir, "external")), + Assert.IsTrue(Directory.Exists(Path.Combine(binDir, "external")), "external/ subdir missing — Clr2 codeBase helper did not deploy"); - Assert.True(Directory.Exists(Path.Combine(binDir, "fr")), + Assert.IsTrue(Directory.Exists(Path.Combine(binDir, "fr")), "fr/ subdir missing — Clr2 culture satellite did not deploy"); // Identity-based copy-local guard: V1 and V2 emit the same filename, so a path @@ -577,20 +577,20 @@ public async ValueTask InitializeAsync() // assembly's version. The redirect collapses on V2; any other value means the // wrong build leaked app-local through the project graph. var stagedSharedDep = Path.Combine(binDir, "NetFxBindingRedirects.Clr2.SharedDep.dll"); - Assert.True(File.Exists(stagedSharedDep), + Assert.IsTrue(File.Exists(stagedSharedDep), $"SharedDep V2 was not staged app-local at {stagedSharedDep}"); var stagedVersion = System.Reflection.AssemblyName.GetAssemblyName(stagedSharedDep).Version?.ToString(); - Assert.Equal("2.0.0.0", stagedVersion); + Assert.AreEqual("2.0.0.0", stagedVersion); } if (NativeAotConsoleExe is not null) - Assert.True(File.Exists(NativeAotConsoleExe), $"NativeAotConsole.exe not found at {NativeAotConsoleExe}"); + Assert.IsTrue(File.Exists(NativeAotConsoleExe), $"NativeAotConsole.exe not found at {NativeAotConsoleExe}"); if (SelfContainedConsoleExe is not null) - Assert.True(File.Exists(SelfContainedConsoleExe), $"SelfContainedConsole not found at {SelfContainedConsoleExe}"); - Assert.True(File.Exists(HelloWorldExe), $"HelloWorld apphost not found at {HelloWorldExe}"); - Assert.True(File.Exists(ComplexAppExe), $"ComplexApp apphost not found at {ComplexAppExe}"); - Assert.True(File.Exists(MinimalApiExe), $"MinimalApi apphost not found at {MinimalApiExe}"); - Assert.True(File.Exists(DottedNameAppDll), $"Dotted.Name.App.dll not found at {DottedNameAppDll}"); - Assert.True(File.Exists(DottedNameAppExe), $"Dotted.Name.App apphost not found at {DottedNameAppExe}"); + Assert.IsTrue(File.Exists(SelfContainedConsoleExe), $"SelfContainedConsole not found at {SelfContainedConsoleExe}"); + Assert.IsTrue(File.Exists(HelloWorldExe), $"HelloWorld apphost not found at {HelloWorldExe}"); + Assert.IsTrue(File.Exists(ComplexAppExe), $"ComplexApp apphost not found at {ComplexAppExe}"); + Assert.IsTrue(File.Exists(MinimalApiExe), $"MinimalApi apphost not found at {MinimalApiExe}"); + Assert.IsTrue(File.Exists(DottedNameAppDll), $"Dotted.Name.App.dll not found at {DottedNameAppDll}"); + Assert.IsTrue(File.Exists(DottedNameAppExe), $"Dotted.Name.App apphost not found at {DottedNameAppExe}"); } /// @@ -598,7 +598,6 @@ public async ValueTask InitializeAsync() /// public ValueTask DisposeAsync() { - GC.SuppressFinalize(this); if (File.Exists(NonDotNetBinaryPath)) { try { File.Delete(NonDotNetBinaryPath); } @@ -668,6 +667,7 @@ private async Task PublishNativeAotProject(string relativePath) // is not resolved from the current directory inside FOR /F). // Clear it for this process only. psi.Environment.Remove("NoDefaultCurrentDirectoryInExePath"); + TestProcessEnvironment.RemoveCodeCoverageVariables(psi); var process = Process.Start(psi)!; var stdout = await process.StandardOutput.ReadToEndAsync(); @@ -723,6 +723,7 @@ private async Task PublishReadyToRunProject(string relativePath, string rid, boo RedirectStandardOutput = true, RedirectStandardError = true, }; + TestProcessEnvironment.RemoveCodeCoverageVariables(psi); var process = Process.Start(psi)!; _ = await process.StandardOutput.ReadToEndAsync(); @@ -787,6 +788,7 @@ private async Task PublishWasmProject(string relativePath, bool runAotCompilatio RedirectStandardOutput = true, RedirectStandardError = true, }; + TestProcessEnvironment.RemoveCodeCoverageVariables(psi); var process = Process.Start(psi)!; _ = await process.StandardOutput.ReadToEndAsync(); @@ -838,6 +840,7 @@ private async Task PublishSelfContainedProject(string relativePath) RedirectStandardOutput = true, RedirectStandardError = true, }; + TestProcessEnvironment.RemoveCodeCoverageVariables(psi); var process = Process.Start(psi)!; var stdout = await process.StandardOutput.ReadToEndAsync(); @@ -888,6 +891,7 @@ private async Task BuildProject(string relativePath) RedirectStandardOutput = true, RedirectStandardError = true, }; + TestProcessEnvironment.RemoveCodeCoverageVariables(psi); var process = Process.Start(psi)!; var stdout = await process.StandardOutput.ReadToEndAsync(); @@ -926,6 +930,7 @@ private static async Task> Capture RedirectStandardError = true, WorkingDirectory = Path.GetDirectoryName(exePath)!, }; + TestProcessEnvironment.RemoveCodeCoverageVariables(psi); var process = Process.Start(psi)!; var stdout = await process.StandardOutput.ReadToEndAsync(); var stderr = await process.StandardError.ReadToEndAsync(); @@ -963,4 +968,4 @@ private static async Task> Capture /// The bound assembly's Assembly.Location, or empty on failure. /// Whether the load succeeded. /// Type-name + message of the load exception, or on success. -public sealed record NetFxOracleEntry(string FullName, string Location, bool Loaded, string? Error); +internal sealed record NetFxOracleEntry(string FullName, string Location, bool Loaded, string? Error); diff --git a/tests/Dotsider.Tests/SampleAssemblyHost.cs b/tests/Dotsider.Tests/SampleAssemblyHost.cs new file mode 100644 index 00000000..e9855bd3 --- /dev/null +++ b/tests/Dotsider.Tests/SampleAssemblyHost.cs @@ -0,0 +1,31 @@ +namespace Dotsider.Tests; + +/// +/// MSTest assembly fixture that builds shared sample assemblies once for this test assembly. +/// +[TestClass] +public static class SampleAssemblyHost +{ + internal static SampleAssemblyFixture Instance { get; private set; } = null!; + + /// + /// Initializes shared sample assemblies before the test assembly runs. + /// + /// The MSTest assembly initialization context. + [AssemblyInitialize] + public static async Task AssemblyInitialize(TestContext context) + { + Instance = new SampleAssemblyFixture(); + await Instance.InitializeAsync(); + } + + /// + /// Cleans up shared sample assemblies after the test assembly completes. + /// + [AssemblyCleanup] + public static async Task AssemblyCleanup() + { + if (Instance is not null) + await Instance.DisposeAsync(); + } +} diff --git a/tests/Dotsider.Tests/ScriptConventionTests.cs b/tests/Dotsider.Tests/ScriptConventionTests.cs index 48c26b99..c6d37561 100644 --- a/tests/Dotsider.Tests/ScriptConventionTests.cs +++ b/tests/Dotsider.Tests/ScriptConventionTests.cs @@ -1,3 +1,4 @@ +using System.Collections.Concurrent; using System.Diagnostics; using System.Text.RegularExpressions; @@ -8,8 +9,11 @@ namespace Dotsider.Tests; /// The checks mirror the repository's Picket-style script policy. /// New file-based apps stay documented, buildable, and friendly to editor hovers. /// +[TestClass] public sealed partial class ScriptConventionTests : IDisposable { + private static readonly ConcurrentDictionary> s_builtFileApps = new(StringComparer.OrdinalIgnoreCase); + private readonly string _tempRoot = Path.Combine(Path.GetTempPath(), "dotsider-script-tests", Guid.NewGuid().ToString("N")); /// @@ -26,21 +30,23 @@ public void Dispose() /// /// Verifies the script README documents the file-based app workflow. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ScriptsReadme_DocumentsFileBasedAppWorkflow() { string root = FindRepositoryRoot(); string readme = File.ReadAllText(Path.Combine(root, "scripts", "README.md")); + string runTestsScript = File.ReadAllText(Path.Combine(root, "scripts", "Run-Tests.cs")); string attributes = File.ReadAllText(Path.Combine(root, ".gitattributes")); Assert.Contains("dotnet run --file ./scripts/Capture-DisasmOracle.cs", readme); - Assert.Contains("dotnet build ./scripts/Capture-DisasmOracle.cs", readme); - Assert.Contains("dotnet clean file-based-apps", readme); + Assert.Contains("dotnet run --file ./scripts/Run-Tests.cs", readme); + Assert.Contains("FullyQualifiedName~", runTestsScript); + Assert.DoesNotContain("dotnet-suggest", runTestsScript); Assert.Contains("#!/usr/bin/env -S dotnet --", readme); - Assert.Contains("documented app", readme); - Assert.Contains("Native architecture oracles", readme); - Assert.Contains("run-runtime-cross-target", readme); + Assert.Contains("Current utilities", readme); Assert.Contains("scripts/*.cs text eol=lf", attributes); + Assert.Contains("scripts/**/*.cs text eol=lf", attributes); } /// @@ -48,7 +54,8 @@ public void ScriptsReadme_DocumentsFileBasedAppWorkflow() /// The outer-loop workflow should refresh artifacts without changing normal PR CI cost. /// Runtime cross-target entries stay pinned to the same container family as dotnet/runtime. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeArchitectureOracleWorkflow_UsesCaptureAppAndRuntimeCrossImages() { string root = FindRepositoryRoot(); @@ -67,19 +74,16 @@ public void NativeArchitectureOracleWorkflow_UsesCaptureAppAndRuntimeCrossImages /// /// Verifies the disassembly oracle app builds and captures stable fake input. /// - [Fact] + [TestMethod] public void CaptureDisasmOracle_BuildsAndCapturesFakeInput() { string root = FindRepositoryRoot(); string scriptPath = Path.Combine(root, "scripts", "Capture-DisasmOracle.cs"); string outputDirectory = Path.Combine(_tempRoot, "oracles"); - var (exitCode, _, _) = RunDotnet( + var (exitCode, _, _) = RunFileApp( root, - "run", - "--file", scriptPath, - "--", "-Architecture", "test", "-Fixture", @@ -91,12 +95,12 @@ public void CaptureDisasmOracle_BuildsAndCapturesFakeInput() "--", "--version"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); string stdoutPath = Path.Combine(outputDirectory, "README.test.oracle.txt"); string metadataPath = Path.Combine(outputDirectory, "README.test.oracle.json"); - Assert.True(File.Exists(stdoutPath), stdoutPath); - Assert.True(File.Exists(metadataPath), metadataPath); - Assert.False(string.IsNullOrWhiteSpace(File.ReadAllText(stdoutPath))); + Assert.IsTrue(File.Exists(stdoutPath), stdoutPath); + Assert.IsTrue(File.Exists(metadataPath), metadataPath); + Assert.IsFalse(string.IsNullOrWhiteSpace(File.ReadAllText(stdoutPath))); string metadata = File.ReadAllText(metadataPath); Assert.Contains("\"Architecture\": \"test\"", metadata); Assert.Contains("\"FixtureSha256\"", metadata); @@ -108,19 +112,16 @@ public void CaptureDisasmOracle_BuildsAndCapturesFakeInput() /// Large disassembly tools can produce very large streams in CI. /// The capture metadata records truncation instead of growing memory without bound. /// - [Fact] + [TestMethod] public void CaptureDisasmOracle_TruncatesLargeOutput() { string root = FindRepositoryRoot(); string scriptPath = Path.Combine(root, "scripts", "Capture-DisasmOracle.cs"); string outputDirectory = Path.Combine(_tempRoot, "truncated-oracles"); - var (exitCode, _, _) = RunDotnet( + var (exitCode, _, _) = RunFileApp( root, - "run", - "--file", scriptPath, - "--", "-Architecture", "test", "-Fixture", @@ -134,7 +135,7 @@ public void CaptureDisasmOracle_TruncatesLargeOutput() "--", "--info"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); string stdout = File.ReadAllText(Path.Combine(outputDirectory, "README.test.oracle.txt")); string metadata = File.ReadAllText(Path.Combine(outputDirectory, "README.test.oracle.json")); Assert.Contains("[output truncated after 20 characters]", stdout); @@ -146,19 +147,16 @@ public void CaptureDisasmOracle_TruncatesLargeOutput() /// Outer-loop capture workflows should upload evidence from failed oracle tools. /// The script exits successfully only when the caller explicitly allows the oracle failure. /// - [Fact] + [TestMethod] public void CaptureDisasmOracle_AllowsOracleFailure() { string root = FindRepositoryRoot(); string scriptPath = Path.Combine(root, "scripts", "Capture-DisasmOracle.cs"); string outputDirectory = Path.Combine(_tempRoot, "failure-oracles"); - var (exitCode, _, _) = RunDotnet( + var (exitCode, _, _) = RunFileApp( root, - "run", - "--file", scriptPath, - "--", "-Architecture", "test", "-Fixture", @@ -171,24 +169,43 @@ public void CaptureDisasmOracle_AllowsOracleFailure() "--", "definitely-not-a-dotnet-command"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); string metadata = File.ReadAllText(Path.Combine(outputDirectory, "README.test.oracle.json")); Assert.Contains("\"OracleExitCode\":", metadata); Assert.DoesNotContain("\"OracleExitCode\": 0", metadata); } + /// + /// Verifies the repeated test runner app builds and exposes usage without running tests. + /// Flake-hunting helpers should remain cheap to validate in normal unit tests. + /// The real suite is exercised by CI through the script's forwarded dotnet test command. + /// + [TestMethod] + public void RunTests_BuildsAndPrintsHelp() + { + string root = FindRepositoryRoot(); + string scriptPath = CopyRunTestsApp(root); + + var (runExitCode, stdout, _) = RunFileApp(root, scriptPath, "-Help"); + Assert.AreEqual(0, runExitCode); + Assert.Contains("-Count", stdout); + Assert.Contains("dotnet test", stdout); + } + /// /// Verifies new top-level utility and decoder test types have three-line summaries. /// This keeps internal helper types documented enough for editor hovers. /// The file list is intentionally scoped to the new file-based app and architecture work. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NewTopLevelTypes_HaveThreeLineSummaries() { string root = FindRepositoryRoot(); string[] relativePaths = [ "scripts/Capture-DisasmOracle.cs", + "scripts/Run-Tests.cs", "scripts/ScriptSupport.cs", "src/Dotsider.Core/Analysis/Disasm/NativeDecoderRegistry.cs", "src/Dotsider.Core/Analysis/Disasm/NativeDecoderSupport.cs", @@ -207,7 +224,7 @@ public void NewTopLevelTypes_HaveThreeLineSummaries() foreach (string relativePath in relativePaths) { string text = File.ReadAllText(Path.Combine(root, relativePath)); - Assert.True(HasThreeLineSummaryBeforeFirstType(text), relativePath); + Assert.IsTrue(HasThreeLineSummaryBeforeFirstType(text), relativePath); } } @@ -240,6 +257,7 @@ private static (int ExitCode, string Stdout, string Stderr) RunDotnet(string wor { startInfo.ArgumentList.Add(argument); } + TestProcessEnvironment.RemoveCodeCoverageVariables(startInfo); using Process process = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start dotnet."); string stdout = process.StandardOutput.ReadToEnd(); @@ -253,10 +271,53 @@ private static (int ExitCode, string Stdout, string Stderr) RunDotnet(string wor return (process.ExitCode, stdout, stderr); } + private string CopyRunTestsApp(string root) + { + string scriptsRoot = Path.Combine(_tempRoot, "run-tests-app", "scripts"); + Directory.CreateDirectory(scriptsRoot); + + File.Copy( + Path.Combine(root, "scripts", "ScriptSupport.cs"), + Path.Combine(scriptsRoot, "ScriptSupport.cs")); + string scriptPath = Path.Combine(scriptsRoot, "Run-Tests.cs"); + File.Copy(Path.Combine(root, "scripts", "Run-Tests.cs"), scriptPath); + return scriptPath; + } + + private static (int ExitCode, string Stdout, string Stderr) RunFileApp(string workingDirectory, string scriptPath, params string[] arguments) + { + EnsureFileAppBuilt(workingDirectory, scriptPath); + + var dotnetArguments = new List + { + "run", + "--file", + scriptPath, + "--no-build", + "--", + }; + dotnetArguments.AddRange(arguments); + return RunDotnet(workingDirectory, [.. dotnetArguments]); + } + + private static void EnsureFileAppBuilt(string workingDirectory, string scriptPath) + { + string fullPath = Path.GetFullPath(scriptPath); + Lazy built = s_builtFileApps.GetOrAdd( + fullPath, + path => new Lazy(() => + { + var (exitCode, _, _) = RunDotnet(workingDirectory, "build", path, "--nologo", "--verbosity", "quiet"); + Assert.AreEqual(0, exitCode); + return true; + })); + _ = built.Value; + } + private static bool HasThreeLineSummaryBeforeFirstType(string text) { Match typeMatch = TopLevelTypeRegex().Match(text); - Assert.True(typeMatch.Success, "No top-level type declaration found."); + Assert.IsTrue(typeMatch.Success, "No top-level type declaration found."); string beforeType = text[..typeMatch.Index]; MatchCollection summaries = SummaryRegex().Matches(beforeType); diff --git a/tests/Dotsider.Tests/SearchFilterTests.cs b/tests/Dotsider.Tests/SearchFilterTests.cs index aa1cf8be..c8644a7b 100644 --- a/tests/Dotsider.Tests/SearchFilterTests.cs +++ b/tests/Dotsider.Tests/SearchFilterTests.cs @@ -6,9 +6,11 @@ namespace Dotsider.Tests; /// /// Tests for search filtering logic across different data types. /// -[Collection("SampleAssemblies")] -public class SearchFilterTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class SearchFilterTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _app; @@ -30,25 +32,27 @@ private Hex1bApp CreateApp() /// /// Verifies strings empty query returns all. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Strings_EmptyQuery_ReturnsAll() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.StringsSourceTab = 1; // Metadata strings state.Search[TabId.Strings].Reset(); // No query var all = state.GetActiveStrings(); - Assert.NotEmpty(all); + Assert.IsNotEmpty(all); } /// /// Verifies strings with query filters results. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Strings_WithQuery_FiltersResults() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.StringsSourceTab = 1; var all = state.GetActiveStrings(); @@ -59,8 +63,8 @@ public void Strings_WithQuery_FiltersResults() state.Search[TabId.Strings].ActivateOrCycle(); state.Search[TabId.Strings].UpdateQuery(target); var filtered = state.GetActiveStrings(); - Assert.True(filtered.Count <= all.Count); - Assert.All(filtered, e => + Assert.IsLessThanOrEqualTo(all.Count, filtered.Count); + TestAssert.All(filtered, e => Assert.Contains(target, e.Value, StringComparison.OrdinalIgnoreCase)); } } @@ -68,26 +72,28 @@ public void Strings_WithQuery_FiltersResults() /// /// Verifies strings no match returns empty. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Strings_NoMatch_ReturnsEmpty() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.StringsSourceTab = 1; state.Search[TabId.Strings].ActivateOrCycle(); state.Search[TabId.Strings].UpdateQuery("zzzNoMatchEver12345zzz"); var filtered = state.GetActiveStrings(); - Assert.Empty(filtered); + Assert.IsEmpty(filtered); } /// /// Verifies strings case insensitive. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Strings_CaseInsensitive() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.StringsSourceTab = 1; var all = state.GetActiveStrings(); if (all.Count > 0) @@ -96,18 +102,19 @@ public void Strings_CaseInsensitive() state.Search[TabId.Strings].ActivateOrCycle(); state.Search[TabId.Strings].UpdateQuery(target); var filtered = state.GetActiveStrings(); - Assert.NotEmpty(filtered); + Assert.IsNotEmpty(filtered); } } /// /// Verifies assembly refs filter by name. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AssemblyRefs_FilterByName() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); var refs = state.Analyzer.AssemblyRefs; if (refs.Count > 0) { @@ -116,35 +123,37 @@ public void AssemblyRefs_FilterByName() .Where(r => $"{r.Name} {r.Version} {r.Culture} {r.PublicKeyToken}" .Contains(query, StringComparison.OrdinalIgnoreCase)) .ToList(); - Assert.NotEmpty(filtered); - Assert.True(filtered.Count <= refs.Count); + Assert.IsNotEmpty(filtered); + Assert.IsLessThanOrEqualTo(refs.Count, filtered.Count); } } /// /// Verifies special characters no regex error. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SpecialCharacters_NoRegexError() { var app = CreateApp(); - using var state = new DotsiderState(app, samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); state.StringsSourceTab = 1; state.Search[TabId.Strings].ActivateOrCycle(); state.Search[TabId.Strings].UpdateQuery("test.*(+?)"); // Should not throw — uses string.Contains, not regex var filtered = state.GetActiveStrings(); - Assert.NotNull(filtered); + Assert.IsNotNull(filtered); } /// /// Verifies diff entries filter by type name. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DiffEntries_FilterByTypeName() { var app = CreateApp(); - using var diffState = new DiffState(app, samples.RichLibraryDll, samples.RichLibraryV2Dll); + using var diffState = new DiffState(app, Samples.RichLibraryDll, Samples.RichLibraryV2Dll); var entries = diffState.DiffResult.TypeDiffs; if (entries.Count > 0) { @@ -155,7 +164,7 @@ public void DiffEntries_FilterByTypeName() var t = e.Right ?? e.Left!; return t.FullName.Contains(query, StringComparison.OrdinalIgnoreCase); }).ToList(); - Assert.NotEmpty(filtered); + Assert.IsNotEmpty(filtered); } } @@ -164,9 +173,9 @@ public void DiffEntries_FilterByTypeName() /// public void Dispose() { + GC.SuppressFinalize(this); _app?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/SearchStateTests.cs b/tests/Dotsider.Tests/SearchStateTests.cs index 77c3f2b9..0b838a72 100644 --- a/tests/Dotsider.Tests/SearchStateTests.cs +++ b/tests/Dotsider.Tests/SearchStateTests.cs @@ -3,82 +3,89 @@ namespace Dotsider.Tests; /// /// Tests for state machine transitions. /// +[TestClass] public class SearchStateTests { /// /// Verifies initial is inactive. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Initial_IsInactive() { var s = new SearchState(); - Assert.False(s.IsActive); - Assert.False(s.IsConfirmed); - Assert.Null(s.Query); - Assert.Equal(-1, s.MatchCount); + Assert.IsFalse(s.IsActive); + Assert.IsFalse(s.IsConfirmed); + Assert.IsNull(s.Query); + Assert.AreEqual(-1, s.MatchCount); } /// /// Verifies activate or cycle inactive to editing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ActivateOrCycle_InactiveToEditing() { var s = new SearchState(); s.ActivateOrCycle(); - Assert.True(s.IsActive); - Assert.False(s.IsConfirmed); + Assert.IsTrue(s.IsActive); + Assert.IsFalse(s.IsConfirmed); } /// /// Verifies activate or cycle editing to inactive. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ActivateOrCycle_EditingToInactive() { var s = new SearchState(); s.ActivateOrCycle(); // Inactive → Editing s.ActivateOrCycle(); // Editing → Inactive - Assert.False(s.IsActive); - Assert.False(s.IsConfirmed); - Assert.Null(s.Query); + Assert.IsFalse(s.IsActive); + Assert.IsFalse(s.IsConfirmed); + Assert.IsNull(s.Query); } /// /// Verifies activate or cycle confirmed to editing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ActivateOrCycle_ConfirmedToEditing() { var s = new SearchState(); s.ActivateOrCycle(); // Inactive → Editing s.UpdateQuery("test"); s.Confirm(); // Editing → Confirmed - Assert.True(s.IsConfirmed); + Assert.IsTrue(s.IsConfirmed); s.ActivateOrCycle(); // Confirmed → Editing - Assert.True(s.IsActive); - Assert.False(s.IsConfirmed); - Assert.Equal("test", s.Query); // Query preserved + Assert.IsTrue(s.IsActive); + Assert.IsFalse(s.IsConfirmed); + Assert.AreEqual("test", s.Query); // Query preserved } /// /// Verifies confirm sets is confirmed. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Confirm_SetsIsConfirmed() { var s = new SearchState(); s.ActivateOrCycle(); s.UpdateQuery("hello"); s.Confirm(); - Assert.True(s.IsActive); - Assert.True(s.IsConfirmed); + Assert.IsTrue(s.IsActive); + Assert.IsTrue(s.IsConfirmed); } /// /// Verifies dismiss clears all. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Dismiss_ClearsAll() { var s = new SearchState(); @@ -87,16 +94,17 @@ public void Dismiss_ClearsAll() s.Confirm(); s.SetMatchCount(5); s.Dismiss(); - Assert.False(s.IsActive); - Assert.False(s.IsConfirmed); - Assert.Null(s.Query); - Assert.Equal(-1, s.MatchCount); + Assert.IsFalse(s.IsActive); + Assert.IsFalse(s.IsConfirmed); + Assert.IsNull(s.Query); + Assert.AreEqual(-1, s.MatchCount); } /// /// Verifies reset clears all. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Reset_ClearsAll() { var s = new SearchState(); @@ -105,63 +113,67 @@ public void Reset_ClearsAll() s.Confirm(); s.SetMatchCount(3); s.Reset(); - Assert.False(s.IsActive); - Assert.False(s.IsConfirmed); - Assert.Null(s.Query); - Assert.Equal(-1, s.MatchCount); + Assert.IsFalse(s.IsActive); + Assert.IsFalse(s.IsConfirmed); + Assert.IsNull(s.Query); + Assert.AreEqual(-1, s.MatchCount); } /// /// Verifies update query resets confirmation. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void UpdateQuery_ResetsConfirmation() { var s = new SearchState(); s.ActivateOrCycle(); s.UpdateQuery("foo"); s.Confirm(); - Assert.True(s.IsConfirmed); + Assert.IsTrue(s.IsConfirmed); s.UpdateQuery("bar"); - Assert.False(s.IsConfirmed); - Assert.Equal("bar", s.Query); - Assert.Equal(-1, s.MatchCount); + Assert.IsFalse(s.IsConfirmed); + Assert.AreEqual("bar", s.Query); + Assert.AreEqual(-1, s.MatchCount); } /// /// Verifies set match count stores value. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SetMatchCount_StoresValue() { var s = new SearchState(); s.SetMatchCount(42); - Assert.Equal(42, s.MatchCount); + Assert.AreEqual(42, s.MatchCount); } /// /// Verifies set match count zero means no matches. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SetMatchCount_ZeroMeansNoMatches() { var s = new SearchState(); s.SetMatchCount(0); - Assert.Equal(0, s.MatchCount); + Assert.AreEqual(0, s.MatchCount); } /// /// Verifies activate or cycle inactive to editing clears query. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ActivateOrCycle_InactiveToEditing_ClearsQuery() { var s = new SearchState(); s.ActivateOrCycle(); // → Editing s.UpdateQuery("test"); s.ActivateOrCycle(); // → Inactive (clears query) - Assert.Null(s.Query); + Assert.IsNull(s.Query); s.ActivateOrCycle(); // → Editing (fresh) - Assert.Null(s.Query); + Assert.IsNull(s.Query); } } diff --git a/tests/Dotsider.Tests/SessionBundleTests.cs b/tests/Dotsider.Tests/SessionBundleTests.cs index 56a49c30..c7a6df14 100644 --- a/tests/Dotsider.Tests/SessionBundleTests.cs +++ b/tests/Dotsider.Tests/SessionBundleTests.cs @@ -14,15 +14,18 @@ namespace Dotsider.Tests; /// list-fields, bundle methods, resolve-assembly, IL navigation, navigate-back, /// and push-assembly over a real headless TUI and diagnostics socket. /// -[Collection("SampleAssemblies")] -public class SessionBundleTests(SampleAssemblyFixture samples) : IAsyncDisposable +[TestClass] +public class SessionBundleTests : IAsyncDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _app; private DotsiderState? _state; private DotsiderDiagnosticsListener? _listener; private CancellationTokenSource? _appCts; + private Task? _appTask; /// /// Starts a headless dotsider TUI with the diagnostics socket listener, @@ -52,14 +55,14 @@ public class SessionBundleTests(SampleAssemblyFixture samples) : IAsyncDisposabl { WorkloadAdapter = _workload, EnableInputCoalescing = false - }); + }); _listener = new DotsiderDiagnosticsListener(() => _state); - _listener.StartListening(); + _listener.StartListening(overridePid: TestSocketIds.NextPid()); // Start the TUI and wait for first render _appCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - _ = _app.RunAsync(_appCts.Token); + _appTask = _app.RunAsync(_appCts.Token); await Task.Delay(100, ct); await TestHelpers.WaitUntilAsync( @@ -75,42 +78,44 @@ await TestHelpers.WaitUntilAsync( /// Verifies that assembly-info for a file-backed library returns the expected /// displayName, isBundleBacked, canSaveInPlace, and preferredRuntimePack properties. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task AssemblyInfo_FileBacked_IncludesProperties() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.RichLibraryDll, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.RichLibraryDll, ct); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "assembly-info" }, ct); - Assert.True(response.Success); + Assert.IsTrue(response.Success); var data = (response.Data as JsonElement?)!.Value; var displayName = data.GetProperty("displayName").GetString(); var fileName = data.GetProperty("fileName").GetString(); - Assert.Equal(fileName, displayName); + Assert.AreEqual(fileName, displayName); - Assert.False(data.GetProperty("isBundleBacked").GetBoolean()); - Assert.True(data.GetProperty("canSaveInPlace").GetBoolean()); - Assert.Equal("Microsoft.NETCore.App", data.GetProperty("preferredRuntimePack").GetString()); + Assert.IsFalse(data.GetProperty("isBundleBacked").GetBoolean()); + Assert.IsTrue(data.GetProperty("canSaveInPlace").GetBoolean()); + Assert.AreEqual("Microsoft.NETCore.App", data.GetProperty("preferredRuntimePack").GetString()); } /// /// Verifies that assembly-info for an ASP.NET Core assembly returns /// "Microsoft.AspNetCore.App" as the preferredRuntimePack. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task AssemblyInfo_AspNetCore_PreferredPack() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.MinimalApiDll, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.MinimalApiDll, ct); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "assembly-info" }, ct); - Assert.True(response.Success); + Assert.IsTrue(response.Success); var data = (response.Data as JsonElement?)!.Value; - Assert.Equal("Microsoft.AspNetCore.App", data.GetProperty("preferredRuntimePack").GetString()); + Assert.AreEqual("Microsoft.AspNetCore.App", data.GetProperty("preferredRuntimePack").GetString()); } // --- Enhanced get-current-view --- @@ -119,50 +124,53 @@ public async Task AssemblyInfo_AspNetCore_PreferredPack() /// Verifies that get-current-view for a managed DLL with an entry point reports /// hasEntryPoint true, hexIsDirty false, isNativeAot false, isNetFramework false. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetCurrentView_ExeHasEntryPoint() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.HelloWorldDll, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.HelloWorldDll, ct); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "get-current-view" }, ct); - Assert.True(response.Success); + Assert.IsTrue(response.Success); var data = (response.Data as JsonElement?)!.Value; - Assert.Equal("General", data.GetProperty("tabLabel").GetString()); - Assert.True(data.GetProperty("hasEntryPoint").GetBoolean()); - Assert.False(data.GetProperty("hexIsDirty").GetBoolean()); - Assert.False(data.GetProperty("isNativeAot").GetBoolean()); - Assert.False(data.GetProperty("isNetFramework").GetBoolean()); + Assert.AreEqual("General", data.GetProperty("tabLabel").GetString()); + Assert.IsTrue(data.GetProperty("hasEntryPoint").GetBoolean()); + Assert.IsFalse(data.GetProperty("hexIsDirty").GetBoolean()); + Assert.IsFalse(data.GetProperty("isNativeAot").GetBoolean()); + Assert.IsFalse(data.GetProperty("isNetFramework").GetBoolean()); } /// /// Verifies that get-current-view for a class library reports hasEntryPoint false. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetCurrentView_LibraryHasNoEntryPoint() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.RichLibraryDll, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.RichLibraryDll, ct); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "get-current-view" }, ct); - Assert.True(response.Success); + Assert.IsTrue(response.Success); var data = (response.Data as JsonElement?)!.Value; - Assert.False(data.GetProperty("hasEntryPoint").GetBoolean()); + Assert.IsFalse(data.GetProperty("hasEntryPoint").GetBoolean()); } /// /// Verifies that get-current-view reports hexIsDirty true after a byte edit /// is applied to the hex document. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetCurrentView_HexIsDirty_AfterEdit() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.HelloWorldDll, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.HelloWorldDll, ct); // Queue a mutation that modifies the hex document to make it dirty _state!.PendingMutations.Enqueue(s => @@ -179,10 +187,10 @@ await TestHelpers.WaitUntilAsync( var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "get-current-view" }, ct); - Assert.True(response.Success); + Assert.IsTrue(response.Success); var data = (response.Data as JsonElement?)!.Value; - Assert.True(data.GetProperty("hexIsDirty").GetBoolean()); + Assert.IsTrue(data.GetProperty("hexIsDirty").GetBoolean()); } // --- list-fields --- @@ -191,46 +199,48 @@ await TestHelpers.WaitUntilAsync( /// Verifies that list-fields returns a non-empty array of field definitions /// for a library with fields. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ListFields_ReturnsFieldDefinitions() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.RichLibraryDll, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.RichLibraryDll, ct); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "list-fields" }, ct); - Assert.True(response.Success); + Assert.IsTrue(response.Success); var data = (response.Data as JsonElement?)!.Value; - Assert.Equal(JsonValueKind.Array, data.ValueKind); - Assert.True(data.GetArrayLength() > 0, "Expected at least one field definition"); + Assert.AreEqual(JsonValueKind.Array, data.ValueKind); + Assert.IsGreaterThan(0, data.GetArrayLength(), "Expected at least one field definition"); } /// /// Verifies that list-fields with a Query parameter returns a filtered subset. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ListFields_WithQuery_Filters() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.RichLibraryDll, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.RichLibraryDll, ct); // Get unfiltered count var allResponse = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "list-fields" }, ct); - Assert.True(allResponse.Success); + Assert.IsTrue(allResponse.Success); var allCount = ((allResponse.Data as JsonElement?)!.Value).GetArrayLength(); // Filter by a specific query that should match fewer results var filteredResponse = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "list-fields", Query = "_counter" }, ct); - Assert.True(filteredResponse.Success); + Assert.IsTrue(filteredResponse.Success); var filteredData = (filteredResponse.Data as JsonElement?)!.Value; - Assert.Equal(JsonValueKind.Array, filteredData.ValueKind); + Assert.AreEqual(JsonValueKind.Array, filteredData.ValueKind); var filteredCount = filteredData.GetArrayLength(); - Assert.True(filteredCount > 0, "Expected at least one field matching '_counter'"); - Assert.True(filteredCount < allCount, "Filtered results should be fewer than all fields"); + Assert.IsGreaterThan(0, filteredCount, "Expected at least one field matching '_counter'"); + Assert.IsLessThan(allCount, filteredCount, "Filtered results should be fewer than all fields"); } // --- Bundle methods --- @@ -238,53 +248,56 @@ public async Task ListFields_WithQuery_Filters() /// /// Verifies that is-bundle returns true for a self-contained single-file executable. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task IsBundle_SelfContainedExe_ReturnsTrue() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.HelloWorldDll, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.HelloWorldDll, ct); var response = await DotsiderClient.SendAsync(socketPath, - new DotsiderRequest { Method = "is-bundle", AssemblyPath = samples.SelfContainedConsoleExe }, ct); - Assert.True(response.Success); + new DotsiderRequest { Method = "is-bundle", AssemblyPath = Samples.SelfContainedConsoleExe }, ct); + Assert.IsTrue(response.Success); var data = (response.Data as JsonElement?)!.Value; - Assert.True(data.GetProperty("isBundle").GetBoolean()); + Assert.IsTrue(data.GetProperty("isBundle").GetBoolean()); } /// /// Verifies that is-bundle returns false for a regular class library DLL. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task IsBundle_RegularDll_ReturnsFalse() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.HelloWorldDll, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.HelloWorldDll, ct); var response = await DotsiderClient.SendAsync(socketPath, - new DotsiderRequest { Method = "is-bundle", AssemblyPath = samples.RichLibraryDll }, ct); - Assert.True(response.Success); + new DotsiderRequest { Method = "is-bundle", AssemblyPath = Samples.RichLibraryDll }, ct); + Assert.IsTrue(response.Success); var data = (response.Data as JsonElement?)!.Value; - Assert.False(data.GetProperty("isBundle").GetBoolean()); + Assert.IsFalse(data.GetProperty("isBundle").GetBoolean()); } /// /// Verifies that get-bundle-manifest returns a manifest with fileCount greater than zero /// for a self-contained single-file executable. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetBundleManifest_ReturnsEntries() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.HelloWorldDll, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.HelloWorldDll, ct); var response = await DotsiderClient.SendAsync(socketPath, - new DotsiderRequest { Method = "get-bundle-manifest", AssemblyPath = samples.SelfContainedConsoleExe }, ct); - Assert.True(response.Success); + new DotsiderRequest { Method = "get-bundle-manifest", AssemblyPath = Samples.SelfContainedConsoleExe }, ct); + Assert.IsTrue(response.Success); var data = (response.Data as JsonElement?)!.Value; - Assert.True(data.GetProperty("fileCount").GetInt32() > 0); + Assert.IsGreaterThan(0, data.GetProperty("fileCount").GetInt32()); } // --- resolve-assembly --- @@ -293,35 +306,37 @@ public async Task GetBundleManifest_ReturnsEntries() /// Verifies that resolve-assembly for a shared framework assembly like System.Runtime /// returns a file-backed result. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ResolveAssembly_SharedFramework_ReturnsFile() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.RichLibraryDll, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.RichLibraryDll, ct); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "resolve-assembly", AssemblyName = "System.Runtime" }, ct); - Assert.True(response.Success); + Assert.IsTrue(response.Success); var data = (response.Data as JsonElement?)!.Value; - Assert.Equal("file", data.GetProperty("kind").GetString()); + Assert.AreEqual("file", data.GetProperty("kind").GetString()); } /// /// Verifies that resolve-assembly for a nonexistent assembly name returns a null resolved value. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ResolveAssembly_Nonexistent_ReturnsNull() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.RichLibraryDll, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.RichLibraryDll, ct); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "resolve-assembly", AssemblyName = "This.Assembly.Does.Not.Exist.At.All" }, ct); - Assert.True(response.Success); + Assert.IsTrue(response.Success); // Unresolved assembly returns null data - Assert.True(response.Data is null + Assert.IsTrue(response.Data is null || (response.Data is JsonElement el && el.ValueKind == JsonValueKind.Null)); } @@ -330,11 +345,12 @@ public async Task ResolveAssembly_Nonexistent_ReturnsNull() /// /// Verifies that navigate-to-il-definition for a local method token returns status "queued". /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task NavigateToIlDefinition_LocalMethod_Queued() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.RichLibraryDll, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.RichLibraryDll, ct); // Navigate to the IL tab first await DotsiderClient.SendAsync(socketPath, @@ -351,7 +367,7 @@ await TestHelpers.WaitUntilAsync( TypeName = "IlNavigationFixture", MethodName = "CallLocalMethod" }, ct); - Assert.True(disasmResponse.Success); + Assert.IsTrue(disasmResponse.Success); var disasmData = (disasmResponse.Data as JsonElement?)!.Value; var instructions = disasmData.GetProperty("instructions"); @@ -374,26 +390,27 @@ await TestHelpers.WaitUntilAsync( } } - Assert.NotNull(callToken); + Assert.IsNotNull(callToken); // Send navigate-to-il-definition with the found token var navResponse = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "navigate-to-il-definition", Token = callToken }, ct); - Assert.True(navResponse.Success); + Assert.IsTrue(navResponse.Success); var navData = (navResponse.Data as JsonElement?)!.Value; - Assert.Equal("queued", navData.GetProperty("status").GetString()); + Assert.AreEqual("queued", navData.GetProperty("status").GetString()); } /// /// Verifies that navigate-to-il-definition for an external method token (Console.WriteLine) /// results in cross-assembly navigation, increasing the navigation depth. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task NavigateToIlDefinition_ExternalMethod_NavigatesCrossAssembly() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.RichLibraryDll, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.RichLibraryDll, ct); // Navigate to the IL tab first await DotsiderClient.SendAsync(socketPath, @@ -410,7 +427,7 @@ await TestHelpers.WaitUntilAsync( TypeName = "IlNavigationFixture", MethodName = "CallExternal" }, ct); - Assert.True(disasmResponse.Success); + Assert.IsTrue(disasmResponse.Success); var disasmData = (disasmResponse.Data as JsonElement?)!.Value; var instructions = disasmData.GetProperty("instructions"); @@ -433,22 +450,22 @@ await TestHelpers.WaitUntilAsync( } } - Assert.NotNull(callToken); + Assert.IsNotNull(callToken); // Get depth before navigation var viewBefore = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "get-current-view" }, ct); - Assert.True(viewBefore.Success); + Assert.IsTrue(viewBefore.Success); var depthBefore = (viewBefore.Data as JsonElement?)!.Value.GetProperty("navigationDepth").GetInt32(); // Send navigate-to-il-definition with the external token var navResponse = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "navigate-to-il-definition", Token = callToken }, ct); - Assert.True(navResponse.Success); + Assert.IsTrue(navResponse.Success); await TestHelpers.WaitUntilAsync( () => _state?.NavigationStack.Count > depthBefore, TimeSpan.FromSeconds(5)); - Assert.True(_state!.NavigationStack.Count > depthBefore); + Assert.IsGreaterThan(depthBefore, _state!.NavigationStack.Count); } // --- navigate-back --- @@ -456,16 +473,17 @@ await TestHelpers.WaitUntilAsync( /// /// Verifies that navigate-back after push-assembly restores the navigation depth. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task NavigateBack_AfterPush_RestoresDepth() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.RichLibraryDll, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.RichLibraryDll, ct); // Push System.Runtime to increase depth var pushResponse = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "push-assembly", AssemblyName = "System.Runtime" }, ct); - Assert.True(pushResponse.Success); + Assert.IsTrue(pushResponse.Success); await TestHelpers.WaitUntilAsync( () => _state?.NavigationStack.Count == 1, TimeSpan.FromSeconds(5)); @@ -473,11 +491,11 @@ await TestHelpers.WaitUntilAsync( // Navigate back var backResponse = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "navigate-back" }, ct); - Assert.True(backResponse.Success); + Assert.IsTrue(backResponse.Success); await TestHelpers.WaitUntilAsync( () => _state?.NavigationStack.Count == 0, TimeSpan.FromSeconds(5)); - Assert.Empty(_state!.NavigationStack); + Assert.IsEmpty(_state!.NavigationStack); } // --- push-assembly --- @@ -486,93 +504,97 @@ await TestHelpers.WaitUntilAsync( /// Verifies that push-assembly by name resolves and pushes System.Runtime, /// increasing the navigation depth. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PushAssembly_ByName_ResolvesAndPushes() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.RichLibraryDll, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.RichLibraryDll, ct); // Get initial depth var viewBefore = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "get-current-view" }, ct); - Assert.True(viewBefore.Success); + Assert.IsTrue(viewBefore.Success); var depthBefore = (viewBefore.Data as JsonElement?)!.Value.GetProperty("navigationDepth").GetInt32(); // Push by assembly name var pushResponse = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "push-assembly", AssemblyName = "System.Runtime" }, ct); - Assert.True(pushResponse.Success); + Assert.IsTrue(pushResponse.Success); await TestHelpers.WaitUntilAsync( () => _state?.NavigationStack.Count > depthBefore, TimeSpan.FromSeconds(5)); - Assert.True(_state!.NavigationStack.Count > depthBefore); + Assert.IsGreaterThan(depthBefore, _state!.NavigationStack.Count); } /// /// Verifies that push-assembly by path opens the specified assembly directly, /// increasing the navigation depth. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PushAssembly_ByPath_OpensDirectly() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.RichLibraryDll, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.RichLibraryDll, ct); // Get initial depth var viewBefore = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "get-current-view" }, ct); - Assert.True(viewBefore.Success); + Assert.IsTrue(viewBefore.Success); var depthBefore = (viewBefore.Data as JsonElement?)!.Value.GetProperty("navigationDepth").GetInt32(); // Push by path var pushResponse = await DotsiderClient.SendAsync(socketPath, - new DotsiderRequest { Method = "push-assembly", AssemblyPath = samples.HelloWorldDll }, ct); - Assert.True(pushResponse.Success); + new DotsiderRequest { Method = "push-assembly", AssemblyPath = Samples.HelloWorldDll }, ct); + Assert.IsTrue(pushResponse.Success); await TestHelpers.WaitUntilAsync( () => _state?.NavigationStack.Count > depthBefore, TimeSpan.FromSeconds(5)); - Assert.True(_state!.NavigationStack.Count > depthBefore); + Assert.IsGreaterThan(depthBefore, _state!.NavigationStack.Count); } /// /// Verifies that push-assembly by path accepts a raw SDK-produced WebAssembly runtime module. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PushAssembly_ByWasmPath_OpensRawWasmModule() { var wasmPath = GetWasmNativePath(); - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.RichLibraryDll, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.RichLibraryDll, ct); var pushResponse = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "push-assembly", AssemblyPath = wasmPath }, ct); - Assert.True(pushResponse.Success); + Assert.IsTrue(pushResponse.Success); await TestHelpers.WaitUntilAsync( () => _state?.Analyzer.BinaryKind == global::Dotsider.Core.Analysis.Models.BinaryKind.Wasm, TimeSpan.FromSeconds(5)); var info = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "assembly-info" }, ct); - Assert.True(info.Success); + Assert.IsTrue(info.Success); var data = (info.Data as JsonElement?)!.Value; - Assert.Equal("wasm", data.GetProperty("binaryKind").GetString()); - Assert.False(data.GetProperty("hasMetadata").GetBoolean()); - Assert.True(data.GetProperty("wasm").GetProperty("definedFunctionCount").GetInt32() > 0); + Assert.AreEqual("wasm", data.GetProperty("binaryKind").GetString()); + Assert.IsFalse(data.GetProperty("hasMetadata").GetBoolean()); + Assert.IsGreaterThan(0, data.GetProperty("wasm").GetProperty("definedFunctionCount").GetInt32()); } /// /// Verifies that push-assembly by path correctly handles an apphost exe /// by loading the companion managed assembly instead of the native host. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PushAssembly_ByApphostPath_OpensCompanion() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.RichLibraryDll, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.RichLibraryDll, ct); var pushResponse = await DotsiderClient.SendAsync(socketPath, - new DotsiderRequest { Method = "push-assembly", AssemblyPath = samples.HelloWorldExe }, ct); - Assert.True(pushResponse.Success); + new DotsiderRequest { Method = "push-assembly", AssemblyPath = Samples.HelloWorldExe }, ct); + Assert.IsTrue(pushResponse.Success); await TestHelpers.WaitUntilAsync( () => _state?.NavigationStack.Count > 0, TimeSpan.FromSeconds(5)); @@ -580,25 +602,26 @@ await TestHelpers.WaitUntilAsync( // Verify the pushed assembly has metadata (companion DLL, not the native host) var info = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "assembly-info" }, ct); - Assert.True(info.Success); + Assert.IsTrue(info.Success); var data = (info.Data as JsonElement?)!.Value; - Assert.True(data.GetProperty("hasMetadata").GetBoolean()); + Assert.IsTrue(data.GetProperty("hasMetadata").GetBoolean()); } /// /// Verifies that push-assembly by path correctly handles a single-file bundle /// by extracting and loading the entry assembly with metadata. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PushAssembly_ByBundlePath_OpensEntryAssembly() { - Assert.NotNull(samples.SelfContainedConsoleExe); - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.RichLibraryDll, ct); + Assert.IsNotNull(Samples.SelfContainedConsoleExe); + var ct = CancellationToken.None; + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.RichLibraryDll, ct); var pushResponse = await DotsiderClient.SendAsync(socketPath, - new DotsiderRequest { Method = "push-assembly", AssemblyPath = samples.SelfContainedConsoleExe }, ct); - Assert.True(pushResponse.Success); + new DotsiderRequest { Method = "push-assembly", AssemblyPath = Samples.SelfContainedConsoleExe }, ct); + Assert.IsTrue(pushResponse.Success); await TestHelpers.WaitUntilAsync( () => _state?.Analyzer.AssemblyName == "SelfContainedConsole", TimeSpan.FromSeconds(5)); @@ -606,27 +629,28 @@ await TestHelpers.WaitUntilAsync( // Verify the pushed assembly has metadata (entry assembly, not the bundle host) var info = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "assembly-info" }, ct); - Assert.True(info.Success); + Assert.IsTrue(info.Success); var data = (info.Data as JsonElement?)!.Value; - Assert.True(data.GetProperty("hasMetadata").GetBoolean()); - Assert.Equal("SelfContainedConsole", data.GetProperty("assemblyName").GetString()); + Assert.IsTrue(data.GetProperty("hasMetadata").GetBoolean()); + Assert.AreEqual("SelfContainedConsole", data.GetProperty("assemblyName").GetString()); } /// /// Verifies that navigate-back restores the apphost dialog when popping /// back to a native apphost with a known companion DLL. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task NavigateBack_ToApphost_RestoresApphostDialog() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; // Open the apphost exe — DotsiderState sets ApphostDialogOpen = true - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.HelloWorldExe, ct); + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.HelloWorldExe, ct); // Push the companion DLL to navigate away from the dialog var pushResponse = await DotsiderClient.SendAsync(socketPath, - new DotsiderRequest { Method = "push-assembly", AssemblyPath = samples.HelloWorldDll }, ct); - Assert.True(pushResponse.Success); + new DotsiderRequest { Method = "push-assembly", AssemblyPath = Samples.HelloWorldDll }, ct); + Assert.IsTrue(pushResponse.Success); await TestHelpers.WaitUntilAsync( () => _state?.NavigationStack.Count > 0, TimeSpan.FromSeconds(5)); @@ -634,26 +658,26 @@ await TestHelpers.WaitUntilAsync( // Navigate back — should return to the apphost and reopen the dialog var backResponse = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "navigate-back" }, ct); - Assert.True(backResponse.Success); + Assert.IsTrue(backResponse.Success); await TestHelpers.WaitUntilAsync( () => _state?.NavigationStack.Count == 0, TimeSpan.FromSeconds(5)); // The analyzer should be the native apphost (no metadata) - Assert.False(_state!.Analyzer.HasMetadata); + Assert.IsFalse(_state!.Analyzer.HasMetadata); // The apphost dialog must be reopened so the user can navigate to the companion - Assert.True(_state.ApphostDialogOpen); - Assert.NotNull(_state.ApphostCompanionDllPath); + Assert.IsTrue(_state.ApphostDialogOpen); + Assert.IsNotNull(_state.ApphostCompanionDllPath); } - private string GetWasmNativePath() + private static string GetWasmNativePath() { - Assert.SkipWhen( - samples.WasmConsoleNativeWasm is null && samples.ReadyToRunConsoleWasmNativeWasm is null, + TestSkip.When( + Samples.WasmConsoleNativeWasm is null && Samples.ReadyToRunConsoleWasmNativeWasm is null, "browser-wasm publish did not run on this leg."); - return samples.WasmConsoleNativeWasm ?? samples.ReadyToRunConsoleWasmNativeWasm!; + return Samples.WasmConsoleNativeWasm ?? Samples.ReadyToRunConsoleWasmNativeWasm!; } /// @@ -665,7 +689,13 @@ public async ValueTask DisposeAsync() _appCts?.Cancel(); if (_listener is not null) await _listener.DisposeAsync(); + if (_appTask is not null) + { + try { await _appTask; } + catch (OperationCanceledException) { } + } _state?.Dispose(); + _app?.Dispose(); if (_terminal is not null) await _terminal.DisposeAsync(); _appCts?.Dispose(); diff --git a/tests/Dotsider.Tests/SessionCliTests.cs b/tests/Dotsider.Tests/SessionCliTests.cs index 1bd4807f..66ffb043 100644 --- a/tests/Dotsider.Tests/SessionCliTests.cs +++ b/tests/Dotsider.Tests/SessionCliTests.cs @@ -7,16 +7,15 @@ namespace Dotsider.Tests; /// /// CLI integration tests for session commands using real Unix domain sockets. /// -public class SessionCliTests : IAsyncLifetime +[TestClass] +public class SessionCliTests { private static readonly string s_projectPath = Path.Combine( TestHelpers.GetRepoRoot(), "src", "Dotsider"); private static readonly string s_buildConfig = DetectBuildConfig(); - // Use a high PID that won't collide with real processes - private const int TestPid = 999_777; - + private int _testPid; private TestDotsiderSocket _dotsiderSocket = null!; private TestRawJsonSocket _hex1bSocket = null!; @@ -35,9 +34,12 @@ private static string DetectBuildConfig() /// /// Prepares the fixture state before tests execute. /// + [TestInitialize] public async ValueTask InitializeAsync() { - var dotsiderPath = SessionDiscovery.GetDotsiderSocketPath(TestPid); + _testPid = TestSocketIds.NextPid(); + + var dotsiderPath = SessionDiscovery.GetDotsiderSocketPath(_testPid); _dotsiderSocket = new TestDotsiderSocket(dotsiderPath); // Register handlers for all protocol methods @@ -96,7 +98,7 @@ public async ValueTask InitializeAsync() _dotsiderSocket.Start(); // Set up hex1b socket for capture tests - var hex1bPath = SessionDiscovery.GetHex1bSocketPath(TestPid); + var hex1bPath = SessionDiscovery.GetHex1bSocketPath(_testPid); _hex1bSocket = new TestRawJsonSocket(hex1bPath); _hex1bSocket.OnRequest(request => { @@ -122,23 +124,25 @@ public async ValueTask InitializeAsync() /// /// Releases fixture state after tests complete. /// + [TestCleanup] public async ValueTask DisposeAsync() { - GC.SuppressFinalize(this); - await _dotsiderSocket.DisposeAsync(); - await _hex1bSocket.DisposeAsync(); + if (_dotsiderSocket is not null) + await _dotsiderSocket.DisposeAsync(); + if (_hex1bSocket is not null) + await _hex1bSocket.DisposeAsync(); } /// /// Verifies sessions info returns assembly and view data. /// - [Fact] + [TestMethod] public async Task Sessions_Info_ReturnsAssemblyAndViewData() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "sessions", "info", TestPid.ToString()); + "sessions", "info", _testPid.ToString()); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("MyApp.dll", stdout); Assert.Contains("MyApp", stdout); Assert.Contains("1.0.0.0", stdout); @@ -148,30 +152,30 @@ public async Task Sessions_Info_ReturnsAssemblyAndViewData() /// /// Verifies sessions info json mode returns json. /// - [Fact] + [TestMethod] public async Task Sessions_Info_JsonMode_ReturnsJson() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "sessions", "info", TestPid.ToString(), "--json"); + "sessions", "info", _testPid.ToString(), "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); var doc = JsonDocument.Parse(stdout); - Assert.True(doc.RootElement.TryGetProperty("assemblyInfo", out var info)); - Assert.Equal("MyApp.dll", info.GetProperty("fileName").GetString()); - Assert.True(doc.RootElement.TryGetProperty("currentView", out var view)); - Assert.Equal(2, view.GetProperty("tab").GetInt32()); + Assert.IsTrue(doc.RootElement.TryGetProperty("assemblyInfo", out var info)); + Assert.AreEqual("MyApp.dll", info.GetProperty("fileName").GetString()); + Assert.IsTrue(doc.RootElement.TryGetProperty("currentView", out var view)); + Assert.AreEqual(2, view.GetProperty("tab").GetInt32()); } /// /// Verifies sessions view returns current view. /// - [Fact] + [TestMethod] public async Task Sessions_View_ReturnsCurrentView() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "sessions", "view", TestPid.ToString()); + "sessions", "view", _testPid.ToString()); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("PE/Metadata", stdout); Assert.Contains("idle", stdout); } @@ -179,52 +183,52 @@ public async Task Sessions_View_ReturnsCurrentView() /// /// Verifies sessions navigate sends tab change. /// - [Fact] + [TestMethod] public async Task Sessions_Navigate_SendsTabChange() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "sessions", "navigate", TestPid.ToString(), "3"); + "sessions", "navigate", _testPid.ToString(), "3"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("tab 3", stdout); } /// /// Verifies sessions capture returns screen content. /// - [Fact] + [TestMethod] public async Task Sessions_Capture_ReturnsScreenContent() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "sessions", "capture", TestPid.ToString()); + "sessions", "capture", _testPid.ToString()); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("[captured-text]", stdout); } /// /// Verifies sessions capture format option removed. /// - [Fact] + [TestMethod] public async Task Sessions_Capture_FormatOptionRemoved() { var (exitCode, _, stderr) = await RunDotsiderAsync( - "sessions", "capture", TestPid.ToString(), "--format", "svg"); + "sessions", "capture", _testPid.ToString(), "--format", "svg"); - Assert.NotEqual(0, exitCode); + Assert.AreNotEqual(0, exitCode); Assert.Contains("--format", stderr); } /// /// Verifies sessions trace events returns events. /// - [Fact] + [TestMethod] public async Task Sessions_TraceEvents_ReturnsEvents() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "sessions", "trace", "events", TestPid.ToString()); + "sessions", "trace", "events", _testPid.ToString()); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("[jit]", stdout); Assert.Contains("MethodLoad", stdout); Assert.Contains("[gc]", stdout); @@ -233,26 +237,26 @@ public async Task Sessions_TraceEvents_ReturnsEvents() /// /// Verifies sessions trace counters returns counters. /// - [Fact] + [TestMethod] public async Task Sessions_TraceCounters_ReturnsCounters() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "sessions", "trace", "counters", TestPid.ToString()); + "sessions", "trace", "counters", _testPid.ToString()); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("cpuUsage", stdout); } /// /// Verifies sessions trace output returns output. /// - [Fact] + [TestMethod] public async Task Sessions_TraceOutput_ReturnsOutput() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "sessions", "trace", "output", TestPid.ToString()); + "sessions", "trace", "output", _testPid.ToString()); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("Hello, World!", stdout); Assert.Contains("[err]", stdout); } @@ -260,43 +264,43 @@ public async Task Sessions_TraceOutput_ReturnsOutput() /// /// Verifies sessions trace start queues trace. /// - [Fact] + [TestMethod] public async Task Sessions_TraceStart_QueuesTrace() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "sessions", "trace", "start", TestPid.ToString()); + "sessions", "trace", "start", _testPid.ToString()); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("Trace start queued", stdout); } /// /// Verifies sessions trace stop stops trace. /// - [Fact] + [TestMethod] public async Task Sessions_TraceStop_StopsTrace() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "sessions", "trace", "stop", TestPid.ToString()); + "sessions", "trace", "stop", _testPid.ToString()); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("Trace stopped", stdout); } /// /// Verifies sessions navigate out of range tab returns error. /// - [Theory] - [InlineData(-1)] - [InlineData(0)] - [InlineData(9)] - [InlineData(99)] + [TestMethod] + [DataRow(-1)] + [DataRow(0)] + [DataRow(9)] + [DataRow(99)] public async Task Sessions_Navigate_OutOfRangeTab_ReturnsError(int tabId) { var (exitCode, _, stderr) = await RunDotsiderAsync( - "sessions", "navigate", TestPid.ToString(), tabId.ToString()); + "sessions", "navigate", _testPid.ToString(), tabId.ToString()); - Assert.NotEqual(0, exitCode); + Assert.AreNotEqual(0, exitCode); Assert.Contains("Tab must be 1-8", stderr); } @@ -304,7 +308,7 @@ public async Task Sessions_Navigate_OutOfRangeTab_ReturnsError(int tabId) /// Verifies that sessions info shows Traceable: yes for NativeAOT assemblies /// even when HasEntryPoint is false, since NativeAOT binaries can be traced directly. /// - [Fact] + [TestMethod] public async Task Sessions_Info_NativeAot_ShowsTraceableYes() { // Override get-current-view to report NativeAOT without entry point @@ -323,9 +327,9 @@ public async Task Sessions_Info_NativeAot_ShowsTraceableYes() }); var (exitCode, stdout, _) = await RunDotsiderAsync( - "sessions", "info", TestPid.ToString()); + "sessions", "info", _testPid.ToString()); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("Traceable: yes", stdout); // Restore original handler for other tests @@ -343,13 +347,13 @@ public async Task Sessions_Info_NativeAot_ShowsTraceableYes() /// /// Verifies sessions info invalid pid returns error. /// - [Fact] + [TestMethod] public async Task Sessions_Info_InvalidPid_ReturnsError() { var (exitCode, _, stderr) = await RunDotsiderAsync( "sessions", "info", "999111"); - Assert.NotEqual(0, exitCode); + Assert.AreNotEqual(0, exitCode); Assert.Contains("Error:", stderr); } @@ -366,6 +370,7 @@ public async Task Sessions_Info_InvalidPid_ReturnsError() RedirectStandardOutput = true, RedirectStandardError = true, }; + TestProcessEnvironment.RemoveCodeCoverageVariables(psi); var process = Process.Start(psi)!; var stdout = await process.StandardOutput.ReadToEndAsync(); diff --git a/tests/Dotsider.Tests/SessionDiagnosticsSocketTests.cs b/tests/Dotsider.Tests/SessionDiagnosticsSocketTests.cs index c5ec921c..884aa0fb 100644 --- a/tests/Dotsider.Tests/SessionDiagnosticsSocketTests.cs +++ b/tests/Dotsider.Tests/SessionDiagnosticsSocketTests.cs @@ -4,6 +4,8 @@ using Hex1b; using Hex1b.Diagnostics; using System.Collections.Concurrent; +using System.Net.Sockets; +using System.Reflection; using System.Text.Json; namespace Dotsider.Tests; @@ -13,22 +15,25 @@ namespace Dotsider.Tests; /// manual terminal setup (McpDiagnosticsPresentationFilter), then exercise the /// hex1b diagnostics socket for sessions capture and screen capture workflows. /// -[Collection("SampleAssemblies")] -public class SessionDiagnosticsSocketTests(SampleAssemblyFixture samples) +[TestClass] +public class SessionDiagnosticsSocketTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// Starts a headless TUI mirroring the production Program.cs setup: /// manual Hex1bTerminal with McpDiagnosticsPresentationFilter. /// Returns the diagnostics socket path and a CTS to stop the app. /// - private async Task<(Hex1bTerminal terminal, Hex1bApp app, McpDiagnosticsPresentationFilter filter, + private static async Task<(Hex1bTerminal terminal, Hex1bApp app, McpDiagnosticsPresentationFilter filter, DotsiderDiagnosticsListener listener, DotsiderState state, Task runTask, CancellationTokenSource cts)> StartTuiAsync() { var pendingMutations = new ConcurrentQueue>(); var workload = new Hex1bAppWorkloadAdapter(); - var filter = new McpDiagnosticsPresentationFilter("dotsider-test"); + var socketId = TestSocketIds.NextPid(); + var filter = CreateDiagnosticsFilter(socketId); var terminalOptions = new Hex1bTerminalOptions { @@ -44,7 +49,7 @@ public class SessionDiagnosticsSocketTests(SampleAssemblyFixture samples) app = new Hex1bApp( ctx => { - state ??= new DotsiderState(app!, samples.HelloWorldDll, pendingMutations); + state ??= new DotsiderState(app!, Samples.HelloWorldDll, pendingMutations); var dotsiderApp = new DotsiderApp(state); return dotsiderApp.Build(ctx); }, @@ -56,7 +61,7 @@ public class SessionDiagnosticsSocketTests(SampleAssemblyFixture samples) }); var listener = new DotsiderDiagnosticsListener(() => state); - listener.StartListening(); + listener.StartListening(overridePid: socketId); // Start the app and wait for state initialization + first render var cts = new CancellationTokenSource(); @@ -77,9 +82,48 @@ await TestHelpers.WaitUntilAsync( TimeSpan.FromSeconds(10), interval: TimeSpan.FromMilliseconds(50)); + await TestHelpers.WaitUntilAsync( + () => CanConnectToSocket(filter.SocketPath), + TimeSpan.FromSeconds(10), + interval: TimeSpan.FromMilliseconds(50)); + return (terminal, app, filter, listener, state!, runTask, cts); } + private static McpDiagnosticsPresentationFilter CreateDiagnosticsFilter(int socketId) + { + var filter = new McpDiagnosticsPresentationFilter($"dotsider-test-{socketId}"); + var socketDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + ".hex1b", + "sockets"); + var socketPath = Path.Combine(socketDirectory, $"{socketId}.diagnostics.socket"); + + typeof(McpDiagnosticsPresentationFilter) + .GetField("_socketPath", BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(filter, socketPath); + + return filter; + } + + private static bool CanConnectToSocket(string socketPath) + { + try + { + using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); + socket.Connect(new UnixDomainSocketEndPoint(socketPath)); + return true; + } + catch (IOException) + { + return false; + } + catch (SocketException) + { + return false; + } + } + /// /// Stops the app cleanly and disposes all resources. /// @@ -103,13 +147,14 @@ private static async Task StopAndDisposeAsync( /// /// Verifies diagnostics socket exists after tui start. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task DiagnosticsSocket_ExistsAfterTuiStart() { var (terminal, app, filter, listener, state, runTask, cts) = await StartTuiAsync(); try { - Assert.True(File.Exists(filter.SocketPath), + Assert.IsTrue(File.Exists(filter.SocketPath), $"Hex1b diagnostics socket was not created at {filter.SocketPath}"); } finally @@ -121,7 +166,8 @@ public async Task DiagnosticsSocket_ExistsAfterTuiStart() /// /// Verifies diagnostics socket removed after dispose. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task DiagnosticsSocket_RemovedAfterDispose() { var (terminal, app, filter, listener, state, runTask, cts) = await StartTuiAsync(); @@ -129,14 +175,15 @@ public async Task DiagnosticsSocket_RemovedAfterDispose() await StopAndDisposeAsync(terminal, app, filter, listener, state, runTask, cts); - Assert.False(File.Exists(socketPath), + Assert.IsFalse(File.Exists(socketPath), $"Hex1b diagnostics socket was not cleaned up at {socketPath}"); } /// /// Verifies sessions capture returns screen content via real socket. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SessionsCapture_ReturnsScreenContent_ViaRealSocket() { var (terminal, app, filter, listener, state, runTask, cts) = await StartTuiAsync(); @@ -146,14 +193,14 @@ public async Task SessionsCapture_ReturnsScreenContent_ViaRealSocket() var requestJson = JsonSerializer.Serialize( new { method = "capture", format = "text" }, DotsiderJsonOptions.Default); - var responseJson = await DotsiderClient.SendRawAsync(filter.SocketPath, requestJson, TestContext.Current.CancellationToken); + var responseJson = await DotsiderClient.SendRawAsync(filter.SocketPath, requestJson, CancellationToken.None); var response = JsonSerializer.Deserialize(responseJson); - Assert.True(response.GetProperty("success").GetBoolean(), "Capture request failed"); - Assert.True(response.TryGetProperty("data", out var data), "Capture response missing data"); + Assert.IsTrue(response.GetProperty("success").GetBoolean(), "Capture request failed"); + Assert.IsTrue(response.TryGetProperty("data", out var data), "Capture response missing data"); var content = data.GetString()!; - Assert.NotEmpty(content); + Assert.IsNotEmpty(content); Assert.Contains("dotsider", content, StringComparison.OrdinalIgnoreCase); } finally @@ -165,7 +212,8 @@ public async Task SessionsCapture_ReturnsScreenContent_ViaRealSocket() /// /// Verifies capture screen returns assembly content via real socket. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task CaptureScreen_ReturnsAssemblyContent_ViaRealSocket() { var (terminal, app, filter, listener, state, runTask, cts) = await StartTuiAsync(); @@ -175,10 +223,10 @@ public async Task CaptureScreen_ReturnsAssemblyContent_ViaRealSocket() var requestJson = JsonSerializer.Serialize( new { method = "capture", format = "text" }, DotsiderJsonOptions.Default); - var responseJson = await DotsiderClient.SendRawAsync(filter.SocketPath, requestJson, TestContext.Current.CancellationToken); + var responseJson = await DotsiderClient.SendRawAsync(filter.SocketPath, requestJson, CancellationToken.None); var response = JsonSerializer.Deserialize(responseJson); - Assert.True(response.GetProperty("success").GetBoolean()); + Assert.IsTrue(response.GetProperty("success").GetBoolean()); var content = response.GetProperty("data").GetString()!; // The General tab shows assembly info — verify real assembly data appears diff --git a/tests/Dotsider.Tests/SessionDiffModeTests.cs b/tests/Dotsider.Tests/SessionDiffModeTests.cs index 4300d684..5d8dcc38 100644 --- a/tests/Dotsider.Tests/SessionDiffModeTests.cs +++ b/tests/Dotsider.Tests/SessionDiffModeTests.cs @@ -12,18 +12,18 @@ namespace Dotsider.Tests; /// instances running in diff mode, and that the diagnostics socket /// responds correctly to assembly-info, get-current-view, and CLI commands. /// -[Collection("SampleAssemblies")] -public class SessionDiffModeTests(SampleAssemblyFixture samples) : IAsyncLifetime +[TestClass] +public class SessionDiffModeTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private static readonly string s_projectPath = Path.Combine( TestHelpers.GetRepoRoot(), "src", "Dotsider"); private static readonly string s_buildConfig = DetectBuildConfig(); - // Use a high PID that won't collide with real processes - private const int DiffPid = 888_001; - - private readonly SampleAssemblyFixture _samples = samples; + private readonly SampleAssemblyFixture _samples = Samples; + private int _diffPid; // Mutable view state exposed to currentViewProvider private int _currentTab; @@ -49,6 +49,7 @@ private static string DetectBuildConfig() /// /// Prepares the fixture state before tests execute. /// + [TestInitialize] public async ValueTask InitializeAsync() { _leftAnalyzer = new AssemblyAnalyzer(_samples.RichLibraryDll); @@ -85,7 +86,8 @@ public async ValueTask InitializeAsync() Tab = _currentTab + 1, FilterMode = _filterMode, }); - _listener.StartListening(overridePid: DiffPid); + _diffPid = TestSocketIds.NextPid(); + _listener.StartListening(overridePid: _diffPid); await Task.CompletedTask; } @@ -93,12 +95,13 @@ public async ValueTask InitializeAsync() /// /// Releases fixture state after tests complete. /// + [TestCleanup] public async ValueTask DisposeAsync() { - GC.SuppressFinalize(this); - await _listener.DisposeAsync(); - _leftAnalyzer.Dispose(); - _rightAnalyzer.Dispose(); + if (_listener is not null) + await _listener.DisposeAsync(); + _leftAnalyzer?.Dispose(); + _rightAnalyzer?.Dispose(); } // --- Session discovery tests --- @@ -106,31 +109,32 @@ public async ValueTask DisposeAsync() /// /// Verifies sessions list finds diff mode instance. /// - [Fact] + [TestMethod] public async Task SessionsList_FindsDiffModeInstance() { var (exitCode, stdout, _) = await RunDotsiderAsync("sessions", "list", "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); var sessions = JsonSerializer.Deserialize(stdout); - Assert.Equal(JsonValueKind.Array, sessions.ValueKind); + Assert.AreEqual(JsonValueKind.Array, sessions.ValueKind); - var diffSession = FindSessionByPid(sessions, DiffPid); - Assert.NotNull(diffSession); - Assert.Equal("diff", diffSession.Value.GetProperty("mode").GetString()); - Assert.Contains("\u2194", diffSession.Value.GetProperty("fileName").GetString()); + var diffSession = FindSessionByPid(sessions, _diffPid); + Assert.IsNotNull(diffSession); + Assert.AreEqual("diff", diffSession.Value.GetProperty("mode").GetString()); + Assert.Contains("\u2194", diffSession.Value.GetProperty("fileName").GetString()!); } /// /// Verifies sessions list table output shows diff mode. /// - [Fact] + [TestMethod] public async Task SessionsList_TableOutput_ShowsDiffMode() { var (exitCode, stdout, _) = await RunDotsiderAsync("sessions", "list"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("Mode", stdout); + Assert.Contains(_diffPid.ToString(), stdout); Assert.Contains("diff", stdout); } @@ -139,26 +143,26 @@ public async Task SessionsList_TableOutput_ShowsDiffMode() /// /// Verifies diff listener responds with real analyzer data. /// - [Fact] + [TestMethod] public async Task DiffListener_RespondsWithRealAnalyzerData() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var response = await DotsiderClient.TryProbeAsync( - SessionDiscovery.GetDotsiderSocketPath(DiffPid), ct); + SessionDiscovery.GetDotsiderSocketPath(_diffPid), ct); - Assert.NotNull(response); - Assert.True(response.Success); + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); var data = response.Data as JsonElement?; - Assert.NotNull(data); - Assert.Equal("diff", data.Value.GetProperty("mode").GetString()); + Assert.IsNotNull(data); + Assert.AreEqual("diff", data.Value.GetProperty("mode").GetString()); // Verify real analyzer data is present var left = data.Value.GetProperty("left"); var right = data.Value.GetProperty("right"); - Assert.Equal("RichLibrary", left.GetProperty("assemblyName").GetString()); - Assert.Equal("RichLibrary", right.GetProperty("assemblyName").GetString()); - Assert.NotEqual( + Assert.AreEqual("RichLibrary", left.GetProperty("assemblyName").GetString()); + Assert.AreEqual("RichLibrary", right.GetProperty("assemblyName").GetString()); + Assert.AreNotEqual( left.GetProperty("assemblyVersion").GetString(), right.GetProperty("assemblyVersion").GetString()); } @@ -166,11 +170,11 @@ public async Task DiffListener_RespondsWithRealAnalyzerData() /// /// Verifies diff listener returns current view. /// - [Fact] + [TestMethod] public async Task DiffListener_ReturnsCurrentView() { - var ct = TestContext.Current.CancellationToken; - var socketPath = SessionDiscovery.GetDotsiderSocketPath(DiffPid); + var ct = CancellationToken.None; + var socketPath = SessionDiscovery.GetDotsiderSocketPath(_diffPid); // Set a non-default tab before querying _currentTab = 2; @@ -179,27 +183,27 @@ public async Task DiffListener_ReturnsCurrentView() var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "get-current-view" }, ct); - Assert.True(response.Success); + Assert.IsTrue(response.Success); var data = response.Data as JsonElement?; - Assert.NotNull(data); - Assert.Equal("diff", data.Value.GetProperty("mode").GetString()); - Assert.Equal(3, data.Value.GetProperty("tab").GetInt32()); - Assert.Equal("addedOnly", data.Value.GetProperty("filterMode").GetString()); + Assert.IsNotNull(data); + Assert.AreEqual("diff", data.Value.GetProperty("mode").GetString()); + Assert.AreEqual(3, data.Value.GetProperty("tab").GetInt32()); + Assert.AreEqual("addedOnly", data.Value.GetProperty("filterMode").GetString()); } /// /// Verifies diff listener rejects unsupported methods. /// - [Fact] + [TestMethod] public async Task DiffListener_RejectsUnsupportedMethods() { - var ct = TestContext.Current.CancellationToken; - var socketPath = SessionDiscovery.GetDotsiderSocketPath(DiffPid); + var ct = CancellationToken.None; + var socketPath = SessionDiscovery.GetDotsiderSocketPath(_diffPid); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "list-types" }, ct); // Should fail because diff mode has no DotsiderState - Assert.False(response.Success); + Assert.IsFalse(response.Success); } // --- CLI sessions info/view tests --- @@ -207,46 +211,46 @@ public async Task DiffListener_RejectsUnsupportedMethods() /// /// Verifies sessions info returns diff mode data. /// - [Fact] + [TestMethod] public async Task SessionsInfo_ReturnsDiffModeData() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "sessions", "info", DiffPid.ToString(), "--json"); + "sessions", "info", _diffPid.ToString(), "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); var doc = JsonDocument.Parse(stdout); var root = doc.RootElement; // assembly-info portion var info = root.GetProperty("assemblyInfo"); - Assert.Equal("diff", info.GetProperty("mode").GetString()); - Assert.True(info.TryGetProperty("left", out _)); - Assert.True(info.TryGetProperty("right", out _)); + Assert.AreEqual("diff", info.GetProperty("mode").GetString()); + Assert.IsTrue(info.TryGetProperty("left", out _)); + Assert.IsTrue(info.TryGetProperty("right", out _)); // get-current-view portion var view = root.GetProperty("currentView"); - Assert.Equal("diff", view.GetProperty("mode").GetString()); - Assert.True(view.TryGetProperty("tab", out _)); - Assert.True(view.TryGetProperty("filterMode", out _)); + Assert.AreEqual("diff", view.GetProperty("mode").GetString()); + Assert.IsTrue(view.TryGetProperty("tab", out _)); + Assert.IsTrue(view.TryGetProperty("filterMode", out _)); } /// /// Verifies sessions view returns diff mode view state. /// - [Fact] + [TestMethod] public async Task SessionsView_ReturnsDiffModeViewState() { _currentTab = 1; var (exitCode, stdout, _) = await RunDotsiderAsync( - "sessions", "view", DiffPid.ToString(), "--json"); + "sessions", "view", _diffPid.ToString(), "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); var doc = JsonDocument.Parse(stdout); var root = doc.RootElement; - Assert.Equal("diff", root.GetProperty("mode").GetString()); - Assert.Equal(2, root.GetProperty("tab").GetInt32()); + Assert.AreEqual("diff", root.GetProperty("mode").GetString()); + Assert.AreEqual(2, root.GetProperty("tab").GetInt32()); } // --- Helpers --- @@ -273,6 +277,7 @@ public async Task SessionsView_ReturnsDiffModeViewState() RedirectStandardOutput = true, RedirectStandardError = true, }; + TestProcessEnvironment.RemoveCodeCoverageVariables(psi); var process = Process.Start(psi)!; var stdout = await process.StandardOutput.ReadToEndAsync(); diff --git a/tests/Dotsider.Tests/SessionNavigateTests.cs b/tests/Dotsider.Tests/SessionNavigateTests.cs index 37b56c9c..0273056c 100644 --- a/tests/Dotsider.Tests/SessionNavigateTests.cs +++ b/tests/Dotsider.Tests/SessionNavigateTests.cs @@ -13,14 +13,18 @@ namespace Dotsider.Tests; /// Starts a headless dotsider TUI, sends protocol requests over UDS, and /// verifies the TUI state changes. /// -[Collection("SampleAssemblies")] -public class SessionNavigateTests(SampleAssemblyFixture samples) : IAsyncDisposable +[TestClass] +public class SessionNavigateTests : IAsyncDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _app; private DotsiderState? _state; private DotsiderDiagnosticsListener? _listener; + private CancellationTokenSource? _appCts; + private Task? _appTask; /// /// Starts a headless dotsider TUI with the diagnostics socket listener, @@ -50,13 +54,14 @@ public class SessionNavigateTests(SampleAssemblyFixture samples) : IAsyncDisposa { WorkloadAdapter = _workload, EnableInputCoalescing = false - }); + }); _listener = new DotsiderDiagnosticsListener(() => _state); - _listener.StartListening(); + _listener.StartListening(overridePid: TestSocketIds.NextPid()); // Start the TUI and wait for first render - _ = _app.RunAsync(ct); + _appCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _appTask = _app.RunAsync(_appCts.Token); await Task.Delay(100, ct); await TestHelpers.WaitUntilAsync( @@ -69,23 +74,24 @@ await TestHelpers.WaitUntilAsync( /// /// Verifies navigate via socket changes active tab. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Navigate_ViaSocket_ChangesActiveTab() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.HelloWorldDll, ct); + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.HelloWorldDll, ct); // Verify we start on tab 0 (General) var viewBefore = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "get-current-view" }, ct); - Assert.True(viewBefore.Success); + Assert.IsTrue(viewBefore.Success); var tabBefore = (viewBefore.Data as JsonElement?)?.GetProperty("tab").GetInt32(); - Assert.Equal(TabId.General + 1, tabBefore); + Assert.AreEqual(TabId.General + 1, tabBefore); // Navigate to Strings tab (user-facing 4) via the socket var navResponse = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "navigate", TabId = TabId.Strings + 1 }, ct); - Assert.True(navResponse.Success); + Assert.IsTrue(navResponse.Success); // Give the TUI time to process the mutation await Task.Delay(500, ct); @@ -93,39 +99,40 @@ public async Task Navigate_ViaSocket_ChangesActiveTab() // Verify the tab actually changed var viewAfter = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "get-current-view" }, ct); - Assert.True(viewAfter.Success); + Assert.IsTrue(viewAfter.Success); var tabAfter = (viewAfter.Data as JsonElement?)?.GetProperty("tab").GetInt32(); - Assert.Equal(TabId.Strings + 1, tabAfter); + Assert.AreEqual(TabId.Strings + 1, tabAfter); } /// /// Reproduces the off-by-one: the CLI and TUI keyboard both show tabs as 1-8, /// so "navigate 1" should land on General (tab index 0), not PE/Metadata. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Navigate_Tab1_LandsOnGeneral_NotPeMetadata() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; - var (_, socketPath) = await StartTuiWithDiagnosticsAsync(samples.HelloWorldDll, ct); + var (_, socketPath) = await StartTuiWithDiagnosticsAsync(Samples.HelloWorldDll, ct); // First move off General so we can verify navigating back var navAway = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "navigate", TabId = TabId.IlInspector + 1 }, ct); - Assert.True(navAway.Success); + Assert.IsTrue(navAway.Success); await Task.Delay(500, ct); // Navigate to tab 1 — the user-facing number for General var navResponse = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "navigate", TabId = 1 }, ct); - Assert.True(navResponse.Success); + Assert.IsTrue(navResponse.Success); await Task.Delay(500, ct); var view = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "get-current-view" }, ct); - Assert.True(view.Success); + Assert.IsTrue(view.Success); var activeTab = (view.Data as JsonElement?)?.GetProperty("tab").GetInt32(); - Assert.Equal(TabId.General + 1, activeTab); + Assert.AreEqual(TabId.General + 1, activeTab); } /// @@ -134,10 +141,18 @@ public async Task Navigate_Tab1_LandsOnGeneral_NotPeMetadata() public async ValueTask DisposeAsync() { GC.SuppressFinalize(this); + _appCts?.Cancel(); if (_listener is not null) await _listener.DisposeAsync(); + if (_appTask is not null) + { + try { await _appTask; } + catch (OperationCanceledException) { } + } _state?.Dispose(); + _app?.Dispose(); if (_terminal is not null) await _terminal.DisposeAsync(); + _appCts?.Dispose(); } } diff --git a/tests/Dotsider.Tests/SessionNugetModeTests.cs b/tests/Dotsider.Tests/SessionNugetModeTests.cs index 53d772e7..2f17f56d 100644 --- a/tests/Dotsider.Tests/SessionNugetModeTests.cs +++ b/tests/Dotsider.Tests/SessionNugetModeTests.cs @@ -12,18 +12,18 @@ namespace Dotsider.Tests; /// instances running in nuget mode, and that the diagnostics socket /// responds correctly to assembly-info, get-current-view, and CLI commands. /// -[Collection("SampleAssemblies")] -public class SessionNugetModeTests(SampleAssemblyFixture samples) : IAsyncLifetime +[TestClass] +public class SessionNugetModeTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private static readonly string s_projectPath = Path.Combine( TestHelpers.GetRepoRoot(), "src", "Dotsider"); private static readonly string s_buildConfig = DetectBuildConfig(); - // Use a high PID that won't collide with real processes - private const int NugetPid = 888_002; - - private readonly SampleAssemblyFixture _samples = samples; + private readonly SampleAssemblyFixture _samples = Samples; + private int _nugetPid; // Mutable view state exposed to currentViewProvider private bool _isBrowsingPackage = true; @@ -50,6 +50,7 @@ private static string DetectBuildConfig() /// /// Prepares the fixture state before tests execute. /// + [TestInitialize] public async ValueTask InitializeAsync() { _packageAnalyzer = new NuGetPackageAnalyzer(_samples.RichLibraryNupkg); @@ -75,7 +76,8 @@ public async ValueTask InitializeAsync() Tab = _selectedDllTab is { } t ? t + 1 : (int?)null, SelectedDll = _selectedDllName, }); - _listener.StartListening(overridePid: NugetPid); + _nugetPid = TestSocketIds.NextPid(); + _listener.StartListening(overridePid: _nugetPid); await Task.CompletedTask; } @@ -83,11 +85,12 @@ public async ValueTask InitializeAsync() /// /// Releases fixture state after tests complete. /// + [TestCleanup] public async ValueTask DisposeAsync() { - GC.SuppressFinalize(this); - await _listener.DisposeAsync(); - _packageAnalyzer.Dispose(); + if (_listener is not null) + await _listener.DisposeAsync(); + _packageAnalyzer?.Dispose(); } // --- Session discovery tests --- @@ -95,31 +98,32 @@ public async ValueTask DisposeAsync() /// /// Verifies sessions list finds nuget mode instance. /// - [Fact] + [TestMethod] public async Task SessionsList_FindsNugetModeInstance() { var (exitCode, stdout, _) = await RunDotsiderAsync("sessions", "list", "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); var sessions = JsonSerializer.Deserialize(stdout); - Assert.Equal(JsonValueKind.Array, sessions.ValueKind); + Assert.AreEqual(JsonValueKind.Array, sessions.ValueKind); - var nugetSession = FindSessionByPid(sessions, NugetPid); - Assert.NotNull(nugetSession); - Assert.Equal("nuget", nugetSession.Value.GetProperty("mode").GetString()); - Assert.Contains(".nupkg", nugetSession.Value.GetProperty("fileName").GetString()); + var nugetSession = FindSessionByPid(sessions, _nugetPid); + Assert.IsNotNull(nugetSession); + Assert.AreEqual("nuget", nugetSession.Value.GetProperty("mode").GetString()); + Assert.Contains(".nupkg", nugetSession.Value.GetProperty("fileName").GetString()!); } /// /// Verifies sessions list table output shows nuget mode. /// - [Fact] + [TestMethod] public async Task SessionsList_TableOutput_ShowsNugetMode() { var (exitCode, stdout, _) = await RunDotsiderAsync("sessions", "list"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("Mode", stdout); + Assert.Contains(_nugetPid.ToString(), stdout); Assert.Contains("nuget", stdout); } @@ -128,55 +132,55 @@ public async Task SessionsList_TableOutput_ShowsNugetMode() /// /// Verifies nuget listener responds with real package data. /// - [Fact] + [TestMethod] public async Task NugetListener_RespondsWithRealPackageData() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var response = await DotsiderClient.TryProbeAsync( - SessionDiscovery.GetDotsiderSocketPath(NugetPid), ct); + SessionDiscovery.GetDotsiderSocketPath(_nugetPid), ct); - Assert.NotNull(response); - Assert.True(response.Success); + Assert.IsNotNull(response); + Assert.IsTrue(response.Success); var data = response.Data as JsonElement?; - Assert.NotNull(data); - Assert.Equal("nuget", data.Value.GetProperty("mode").GetString()); + Assert.IsNotNull(data); + Assert.AreEqual("nuget", data.Value.GetProperty("mode").GetString()); // Verify real package data is present - Assert.Equal("RichLibrary", data.Value.GetProperty("packageId").GetString()); - Assert.Equal("2.5.1", data.Value.GetProperty("packageVersion").GetString()); - Assert.True(data.Value.GetProperty("dllCount").GetInt32() > 0); + Assert.AreEqual("RichLibrary", data.Value.GetProperty("packageId").GetString()); + Assert.AreEqual("2.5.1", data.Value.GetProperty("packageVersion").GetString()); + Assert.IsGreaterThan(0, data.Value.GetProperty("dllCount").GetInt32()); } /// /// Verifies nuget listener returns current view browsing package. /// - [Fact] + [TestMethod] public async Task NugetListener_ReturnsCurrentView_BrowsingPackage() { - var ct = TestContext.Current.CancellationToken; - var socketPath = SessionDiscovery.GetDotsiderSocketPath(NugetPid); + var ct = CancellationToken.None; + var socketPath = SessionDiscovery.GetDotsiderSocketPath(_nugetPid); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "get-current-view" }, ct); - Assert.True(response.Success); + Assert.IsTrue(response.Success); var data = response.Data as JsonElement?; - Assert.NotNull(data); - Assert.Equal("nuget", data.Value.GetProperty("mode").GetString()); - Assert.True(data.Value.GetProperty("isBrowsingPackage").GetBoolean()); + Assert.IsNotNull(data); + Assert.AreEqual("nuget", data.Value.GetProperty("mode").GetString()); + Assert.IsTrue(data.Value.GetProperty("isBrowsingPackage").GetBoolean()); // Tab is null when browsing package — omitted from JSON (WhenWritingNull) - Assert.False(data.Value.TryGetProperty("tab", out _)); + Assert.IsFalse(data.Value.TryGetProperty("tab", out _)); } /// /// Verifies nuget listener returns current view dll selected. /// - [Fact] + [TestMethod] public async Task NugetListener_ReturnsCurrentView_DllSelected() { - var ct = TestContext.Current.CancellationToken; - var socketPath = SessionDiscovery.GetDotsiderSocketPath(NugetPid); + var ct = CancellationToken.None; + var socketPath = SessionDiscovery.GetDotsiderSocketPath(_nugetPid); // Simulate DLL selection _isBrowsingPackage = false; @@ -186,28 +190,28 @@ public async Task NugetListener_ReturnsCurrentView_DllSelected() var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "get-current-view" }, ct); - Assert.True(response.Success); + Assert.IsTrue(response.Success); var data = response.Data as JsonElement?; - Assert.NotNull(data); - Assert.Equal("nuget", data.Value.GetProperty("mode").GetString()); - Assert.False(data.Value.GetProperty("isBrowsingPackage").GetBoolean()); - Assert.Equal(TabId.Strings + 1, data.Value.GetProperty("tab").GetInt32()); - Assert.Equal("RichLibrary.dll", data.Value.GetProperty("selectedDll").GetString()); + Assert.IsNotNull(data); + Assert.AreEqual("nuget", data.Value.GetProperty("mode").GetString()); + Assert.IsFalse(data.Value.GetProperty("isBrowsingPackage").GetBoolean()); + Assert.AreEqual(TabId.Strings + 1, data.Value.GetProperty("tab").GetInt32()); + Assert.AreEqual("RichLibrary.dll", data.Value.GetProperty("selectedDll").GetString()); } /// /// Verifies nuget listener rejects unsupported methods. /// - [Fact] + [TestMethod] public async Task NugetListener_RejectsUnsupportedMethods() { - var ct = TestContext.Current.CancellationToken; - var socketPath = SessionDiscovery.GetDotsiderSocketPath(NugetPid); + var ct = CancellationToken.None; + var socketPath = SessionDiscovery.GetDotsiderSocketPath(_nugetPid); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "list-types" }, ct); // Should fail because nuget mode has no DotsiderState - Assert.False(response.Success); + Assert.IsFalse(response.Success); } // --- CLI sessions info/view tests --- @@ -215,43 +219,43 @@ public async Task NugetListener_RejectsUnsupportedMethods() /// /// Verifies sessions info returns nuget mode data. /// - [Fact] + [TestMethod] public async Task SessionsInfo_ReturnsNugetModeData() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "sessions", "info", NugetPid.ToString(), "--json"); + "sessions", "info", _nugetPid.ToString(), "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); var doc = JsonDocument.Parse(stdout); var root = doc.RootElement; // assembly-info portion var info = root.GetProperty("assemblyInfo"); - Assert.Equal("nuget", info.GetProperty("mode").GetString()); - Assert.Equal("RichLibrary", info.GetProperty("packageId").GetString()); - Assert.True(info.GetProperty("dllCount").GetInt32() > 0); + Assert.AreEqual("nuget", info.GetProperty("mode").GetString()); + Assert.AreEqual("RichLibrary", info.GetProperty("packageId").GetString()); + Assert.IsGreaterThan(0, info.GetProperty("dllCount").GetInt32()); // get-current-view portion var view = root.GetProperty("currentView"); - Assert.Equal("nuget", view.GetProperty("mode").GetString()); - Assert.True(view.TryGetProperty("isBrowsingPackage", out _)); + Assert.AreEqual("nuget", view.GetProperty("mode").GetString()); + Assert.IsTrue(view.TryGetProperty("isBrowsingPackage", out _)); } /// /// Verifies sessions view returns nuget mode view state. /// - [Fact] + [TestMethod] public async Task SessionsView_ReturnsNugetModeViewState() { var (exitCode, stdout, _) = await RunDotsiderAsync( - "sessions", "view", NugetPid.ToString(), "--json"); + "sessions", "view", _nugetPid.ToString(), "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); var doc = JsonDocument.Parse(stdout); var root = doc.RootElement; - Assert.Equal("nuget", root.GetProperty("mode").GetString()); - Assert.True(root.TryGetProperty("isBrowsingPackage", out _)); + Assert.AreEqual("nuget", root.GetProperty("mode").GetString()); + Assert.IsTrue(root.TryGetProperty("isBrowsingPackage", out _)); } // --- Helpers --- @@ -278,6 +282,7 @@ public async Task SessionsView_ReturnsNugetModeViewState() RedirectStandardOutput = true, RedirectStandardError = true, }; + TestProcessEnvironment.RemoveCodeCoverageVariables(psi); var process = Process.Start(psi)!; var stdout = await process.StandardOutput.ReadToEndAsync(); diff --git a/tests/Dotsider.Tests/SessionNugetNavigateTests.cs b/tests/Dotsider.Tests/SessionNugetNavigateTests.cs index d9d9dce5..4e4f2552 100644 --- a/tests/Dotsider.Tests/SessionNugetNavigateTests.cs +++ b/tests/Dotsider.Tests/SessionNugetNavigateTests.cs @@ -13,14 +13,18 @@ namespace Dotsider.Tests; /// exactly like Program.RunTui NuGet mode. Verifies navigation, mutation /// draining, get-current-view, search, and start-trace via the diagnostics socket. /// -[Collection("SampleAssemblies")] -public class SessionNugetNavigateTests(SampleAssemblyFixture samples) : IAsyncDisposable +[TestClass] +public class SessionNugetNavigateTests : IAsyncDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _app; private NuGetState? _nugetState; private DotsiderDiagnosticsListener? _listener; + private CancellationTokenSource? _appCts; + private Task? _appTask; /// /// Starts a headless NuGet TUI with the diagnostics socket listener, @@ -82,10 +86,11 @@ public class SessionNugetNavigateTests(SampleAssemblyFixture samples) : IAsyncDi Tab = s.SelectedDllState is { } dll ? dll.CurrentTab + 1 : (int?)null, SelectedDll = s.SelectedDllEntry?.Name, }; - }); - _listener.StartListening(); + }); + _listener.StartListening(overridePid: TestSocketIds.NextPid()); - _ = _app.RunAsync(ct); + _appCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _appTask = _app.RunAsync(_appCts.Token); await Task.Delay(100, ct); await TestHelpers.WaitUntilAsync( @@ -112,46 +117,48 @@ private void OpenFirstDll() /// /// Verifies navigate via socket changes dll tab. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Navigate_ViaSocket_ChangesDllTab() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartNugetTuiWithDiagnosticsAsync(samples.RichLibraryNupkg, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartNugetTuiWithDiagnosticsAsync(Samples.RichLibraryNupkg, ct); // Open a DLL so SelectedDllState is available OpenFirstDll(); // Verify initial tab is 0 (General) - Assert.Equal(TabId.General, _nugetState!.SelectedDllState!.CurrentTab); + Assert.AreEqual(TabId.General, _nugetState!.SelectedDllState!.CurrentTab); // Navigate to Strings tab via the socket (1-based: Strings = 4) var navResponse = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "navigate", TabId = TabId.Strings + 1 }, ct); - Assert.True(navResponse.Success); + Assert.IsTrue(navResponse.Success); // Wait for the render loop to drain the mutation queue await TestHelpers.WaitUntilAsync( () => _nugetState.SelectedDllState!.CurrentTab == TabId.Strings, TimeSpan.FromSeconds(10)); - Assert.Equal(TabId.Strings, _nugetState.SelectedDllState.CurrentTab); + Assert.AreEqual(TabId.Strings, _nugetState.SelectedDllState.CurrentTab); } /// /// Verifies get current view reflects navigated tab. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetCurrentView_ReflectsNavigatedTab() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartNugetTuiWithDiagnosticsAsync(samples.RichLibraryNupkg, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartNugetTuiWithDiagnosticsAsync(Samples.RichLibraryNupkg, ct); OpenFirstDll(); // Navigate to PE/Metadata tab (1-based: PE/Metadata = 2) var navResponse = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "navigate", TabId = TabId.PeMetadata + 1 }, ct); - Assert.True(navResponse.Success); + Assert.IsTrue(navResponse.Success); await TestHelpers.WaitUntilAsync( () => _nugetState!.SelectedDllState!.CurrentTab == TabId.PeMetadata, @@ -160,14 +167,14 @@ await TestHelpers.WaitUntilAsync( // get-current-view should report the navigated tab var viewResponse = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "get-current-view" }, ct); - Assert.True(viewResponse.Success); + Assert.IsTrue(viewResponse.Success); var data = viewResponse.Data as JsonElement?; - Assert.NotNull(data); - Assert.Equal("nuget", data.Value.GetProperty("mode").GetString()); - Assert.False(data.Value.GetProperty("isBrowsingPackage").GetBoolean()); - Assert.Equal(TabId.PeMetadata + 1, data.Value.GetProperty("tab").GetInt32()); - Assert.Equal( + Assert.IsNotNull(data); + Assert.AreEqual("nuget", data.Value.GetProperty("mode").GetString()); + Assert.IsFalse(data.Value.GetProperty("isBrowsingPackage").GetBoolean()); + Assert.AreEqual(TabId.PeMetadata + 1, data.Value.GetProperty("tab").GetInt32()); + Assert.AreEqual( _nugetState!.SelectedDllEntry!.Name, data.Value.GetProperty("selectedDll").GetString()); } @@ -175,73 +182,77 @@ await TestHelpers.WaitUntilAsync( /// /// Verifies get current view before dll opened shows browsing package. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetCurrentView_BeforeDllOpened_ShowsBrowsingPackage() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartNugetTuiWithDiagnosticsAsync(samples.RichLibraryNupkg, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartNugetTuiWithDiagnosticsAsync(Samples.RichLibraryNupkg, ct); // Don't open a DLL — should still be browsing the package var viewResponse = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "get-current-view" }, ct); - Assert.True(viewResponse.Success); + Assert.IsTrue(viewResponse.Success); var data = viewResponse.Data as JsonElement?; - Assert.NotNull(data); - Assert.Equal("nuget", data.Value.GetProperty("mode").GetString()); - Assert.True(data.Value.GetProperty("isBrowsingPackage").GetBoolean()); + Assert.IsNotNull(data); + Assert.AreEqual("nuget", data.Value.GetProperty("mode").GetString()); + Assert.IsTrue(data.Value.GetProperty("isBrowsingPackage").GetBoolean()); // Tab is null when browsing package — omitted from JSON (WhenWritingNull) - Assert.False(data.Value.TryGetProperty("tab", out _)); + Assert.IsFalse(data.Value.TryGetProperty("tab", out _)); } /// /// Verifies search via socket succeeds. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Search_ViaSocket_Succeeds() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartNugetTuiWithDiagnosticsAsync(samples.RichLibraryNupkg, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartNugetTuiWithDiagnosticsAsync(Samples.RichLibraryNupkg, ct); OpenFirstDll(); // Search should succeed through the NuGet listener's getState → SelectedDllState pipeline var searchResponse = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "search", Query = "test" }, ct); - Assert.True(searchResponse.Success); + Assert.IsTrue(searchResponse.Success); } /// /// Verifies start trace fails for library dll. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task StartTrace_FailsForLibraryDll() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartNugetTuiWithDiagnosticsAsync(samples.RichLibraryNupkg, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartNugetTuiWithDiagnosticsAsync(Samples.RichLibraryNupkg, ct); OpenFirstDll(); // Library DLLs have no entry point — start-trace should fail var traceResponse = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "start-trace" }, ct); - Assert.False(traceResponse.Success); - Assert.Contains("entry point", traceResponse.Error, StringComparison.OrdinalIgnoreCase); + Assert.IsFalse(traceResponse.Success); + Assert.Contains("entry point", traceResponse.Error!, StringComparison.OrdinalIgnoreCase); } /// /// Verifies navigate before dll opened fails. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Navigate_BeforeDllOpened_Fails() { - var ct = TestContext.Current.CancellationToken; - var (_, socketPath) = await StartNugetTuiWithDiagnosticsAsync(samples.RichLibraryNupkg, ct); + var ct = CancellationToken.None; + var (_, socketPath) = await StartNugetTuiWithDiagnosticsAsync(Samples.RichLibraryNupkg, ct); // Don't open a DLL — navigate should fail because getState returns null var navResponse = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "navigate", TabId = TabId.Strings + 1 }, ct); - Assert.False(navResponse.Success); + Assert.IsFalse(navResponse.Success); } /// @@ -250,10 +261,18 @@ public async Task Navigate_BeforeDllOpened_Fails() public async ValueTask DisposeAsync() { GC.SuppressFinalize(this); + _appCts?.Cancel(); if (_listener is not null) await _listener.DisposeAsync(); + if (_appTask is not null) + { + try { await _appTask; } + catch (OperationCanceledException) { } + } _nugetState?.Dispose(); + _app?.Dispose(); if (_terminal is not null) await _terminal.DisposeAsync(); + _appCts?.Dispose(); } } diff --git a/tests/Dotsider.Tests/SessionSymbolTests.cs b/tests/Dotsider.Tests/SessionSymbolTests.cs index ea00d2e7..0967d408 100644 --- a/tests/Dotsider.Tests/SessionSymbolTests.cs +++ b/tests/Dotsider.Tests/SessionSymbolTests.cs @@ -12,15 +12,18 @@ namespace Dotsider.Tests; /// End-to-end protocol tests for the get-native-symbols session method and the symbol /// provenance fields on assembly-info, over a real headless TUI and diagnostics socket. /// -[Collection("SampleAssemblies")] -public class SessionSymbolTests(SampleAssemblyFixture samples) : IAsyncDisposable +[TestClass] +public class SessionSymbolTests : IAsyncDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _app; private DotsiderState? _state; private DotsiderDiagnosticsListener? _listener; private CancellationTokenSource? _appCts; + private Task? _appTask; /// /// Starts a headless dotsider TUI with the diagnostics socket listener, @@ -49,13 +52,13 @@ private async Task StartTuiWithDiagnosticsAsync(string dllPath, Cancella { WorkloadAdapter = _workload, EnableInputCoalescing = false - }); + }); _listener = new DotsiderDiagnosticsListener(() => _state); - _listener.StartListening(); + _listener.StartListening(overridePid: TestSocketIds.NextPid()); _appCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - _ = _app.RunAsync(_appCts.Token); + _appTask = _app.RunAsync(_appCts.Token); await Task.Delay(100, ct); await TestHelpers.WaitUntilAsync( @@ -69,65 +72,69 @@ await TestHelpers.WaitUntilAsync( /// Verifies get-native-symbols returns the symbol list with provenance for a Native /// AOT binary. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetNativeSymbols_NativeAot_ReturnsSymbolsWithProvenance() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var ct = TestContext.Current.CancellationToken; - var socketPath = await StartTuiWithDiagnosticsAsync(samples.NativeAotConsoleExe!, ct); + var ct = CancellationToken.None; + var socketPath = await StartTuiWithDiagnosticsAsync(Samples.NativeAotConsoleExe!, ct); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "get-native-symbols" }, ct); - Assert.True(response.Success); + Assert.IsTrue(response.Success); var data = (response.Data as JsonElement?)!.Value; - Assert.True(data.GetProperty("symbols").GetArrayLength() > 0); - Assert.False(string.IsNullOrEmpty(data.GetProperty("source").GetString())); - Assert.False(string.IsNullOrEmpty(data.GetProperty("status").GetString())); + Assert.IsGreaterThan(0, data.GetProperty("symbols").GetArrayLength()); + Assert.IsFalse(string.IsNullOrEmpty(data.GetProperty("source").GetString())); + Assert.IsFalse(string.IsNullOrEmpty(data.GetProperty("status").GetString())); } /// Verifies get-native-symbols fails cleanly for a managed assembly. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetNativeSymbols_Managed_Fails() { - var ct = TestContext.Current.CancellationToken; - var socketPath = await StartTuiWithDiagnosticsAsync(samples.RichLibraryDll, ct); + var ct = CancellationToken.None; + var socketPath = await StartTuiWithDiagnosticsAsync(Samples.RichLibraryDll, ct); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "get-native-symbols" }, ct); - Assert.False(response.Success); - Assert.Contains("no native symbols", response.Error); + Assert.IsFalse(response.Success); + Assert.Contains("no native symbols", response.Error!); } /// /// Verifies get-native-symbols returns WebAssembly function symbols from a raw SDK /// browser-wasm runtime module opened in the TUI. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GetNativeSymbols_Wasm_ReturnsSymbolsWithProvenance() { var wasmPath = GetWasmNativePath(); - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var socketPath = await StartTuiWithDiagnosticsAsync(wasmPath, ct); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "get-native-symbols" }, ct); - Assert.True(response.Success); + Assert.IsTrue(response.Success); var data = (response.Data as JsonElement?)!.Value; - Assert.Equal("webAssembly", data.GetProperty("source").GetString()); - Assert.Equal("wasm32", data.GetProperty("architecture").GetString()); - Assert.True(data.GetProperty("symbols").GetArrayLength() > 0); + Assert.AreEqual("webAssembly", data.GetProperty("source").GetString()); + Assert.AreEqual("wasm32", data.GetProperty("architecture").GetString()); + Assert.IsGreaterThan(0, data.GetProperty("symbols").GetArrayLength()); } /// /// Verifies disassemble-native accepts WebAssembly func:N identifiers through /// the diagnostics socket, matching the CLI and MCP native-disassembly surfaces. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task DisassembleNative_Wasm_ByFunctionIndex_ReturnsInstructions() { var wasmPath = GetWasmNativePath(); @@ -139,46 +146,47 @@ public async Task DisassembleNative_Wasm_ByFunctionIndex_ReturnsInstructions() funcAlias = symbol.Aliases.First(static alias => alias.StartsWith("func:", StringComparison.Ordinal)); } - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var socketPath = await StartTuiWithDiagnosticsAsync(wasmPath, ct); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "disassemble-native", SymbolName = funcAlias }, ct); - Assert.True(response.Success); + Assert.IsTrue(response.Success); var data = (response.Data as JsonElement?)!.Value; - Assert.Equal("Wasm32", data.GetProperty("architecture").GetString()); - Assert.True(data.GetProperty("instructions").GetArrayLength() > 0); + Assert.AreEqual("Wasm32", data.GetProperty("architecture").GetString()); + Assert.IsGreaterThan(0, data.GetProperty("instructions").GetArrayLength()); } /// /// Verifies assembly-info carries the native symbol count, source, and status for a /// Native AOT binary. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task AssemblyInfo_NativeAot_CarriesSymbolProvenance() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var ct = TestContext.Current.CancellationToken; - var socketPath = await StartTuiWithDiagnosticsAsync(samples.NativeAotConsoleExe!, ct); + var ct = CancellationToken.None; + var socketPath = await StartTuiWithDiagnosticsAsync(Samples.NativeAotConsoleExe!, ct); var response = await DotsiderClient.SendAsync(socketPath, new DotsiderRequest { Method = "assembly-info" }, ct); - Assert.True(response.Success); + Assert.IsTrue(response.Success); var data = (response.Data as JsonElement?)!.Value; - Assert.True(data.GetProperty("nativeSymbolCount").GetInt32() > 0); - Assert.False(string.IsNullOrEmpty(data.GetProperty("nativeSymbolSource").GetString())); - Assert.False(string.IsNullOrEmpty(data.GetProperty("nativeSymbolStatus").GetString())); + Assert.IsGreaterThan(0, data.GetProperty("nativeSymbolCount").GetInt32()); + Assert.IsFalse(string.IsNullOrEmpty(data.GetProperty("nativeSymbolSource").GetString())); + Assert.IsFalse(string.IsNullOrEmpty(data.GetProperty("nativeSymbolStatus").GetString())); } - private string GetWasmNativePath() + private static string GetWasmNativePath() { - Assert.SkipWhen(samples.WasmConsoleNativeWasm is null && samples.ReadyToRunConsoleWasmNativeWasm is null, + TestSkip.When(Samples.WasmConsoleNativeWasm is null && Samples.ReadyToRunConsoleWasmNativeWasm is null, "browser-wasm publish did not run on this leg."); - return samples.WasmConsoleNativeWasm ?? samples.ReadyToRunConsoleWasmNativeWasm!; + return Samples.WasmConsoleNativeWasm ?? Samples.ReadyToRunConsoleWasmNativeWasm!; } /// @@ -190,7 +198,13 @@ public async ValueTask DisposeAsync() _appCts?.Cancel(); if (_listener is not null) await _listener.DisposeAsync(); + if (_appTask is not null) + { + try { await _appTask; } + catch (OperationCanceledException) { } + } _state?.Dispose(); + _app?.Dispose(); if (_terminal is not null) await _terminal.DisposeAsync(); _appCts?.Dispose(); diff --git a/tests/Dotsider.Tests/SingleFileBundleAnalysisTests.cs b/tests/Dotsider.Tests/SingleFileBundleAnalysisTests.cs index 93851e15..89b1caa6 100644 --- a/tests/Dotsider.Tests/SingleFileBundleAnalysisTests.cs +++ b/tests/Dotsider.Tests/SingleFileBundleAnalysisTests.cs @@ -12,9 +12,11 @@ namespace Dotsider.Tests; /// analyzable — entry assembly loads, references are populated, and drill-down /// into bundled System assemblies succeeds. /// -[Collection("SampleAssemblies")] -public sealed class SingleFileBundleAnalysisTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public sealed class SingleFileBundleAnalysisTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _hex1bApp; @@ -53,17 +55,18 @@ public sealed class SingleFileBundleAnalysisTests(SampleAssemblyFixture samples) /// produces a /// with a valid entry analyzer. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void OpenSingleFileExe_LoadsEntryAssembly() { - Assert.NotNull(samples.SelfContainedConsoleExe); + Assert.IsNotNull(Samples.SelfContainedConsoleExe); - var result = AssemblyLoader.Open(samples.SelfContainedConsoleExe!); - var bundle = Assert.IsType(result); + var result = AssemblyLoader.Open(Samples.SelfContainedConsoleExe!); + var bundle = Assert.IsExactInstanceOfType(result); - Assert.True(bundle.EntryAnalyzer.HasMetadata); - Assert.Equal("SelfContainedConsole", bundle.EntryAnalyzer.AssemblyName); - Assert.Equal(samples.SelfContainedConsoleExe, bundle.EntryAnalyzer.SourceBundlePath); + Assert.IsTrue(bundle.EntryAnalyzer.HasMetadata); + Assert.AreEqual("SelfContainedConsole", bundle.EntryAnalyzer.AssemblyName); + Assert.AreEqual(Samples.SelfContainedConsoleExe, bundle.EntryAnalyzer.SourceBundlePath); bundle.EntryAnalyzer.Dispose(); } @@ -72,15 +75,15 @@ public void OpenSingleFileExe_LoadsEntryAssembly() /// Verifies that the entry assembly extracted from a single-file bundle /// includes System.Runtime in its assembly references. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void OpenSingleFileExe_AssemblyRefs_ContainsSystemRuntime() { - Assert.NotNull(samples.SelfContainedConsoleExe); - var result = AssemblyLoader.Open(samples.SelfContainedConsoleExe!); - var bundle = Assert.IsType(result); + Assert.IsNotNull(Samples.SelfContainedConsoleExe); + var result = AssemblyLoader.Open(Samples.SelfContainedConsoleExe!); + var bundle = Assert.IsExactInstanceOfType(result); - Assert.Contains(bundle.EntryAnalyzer.AssemblyRefs, - r => r.Name == "System.Runtime"); + Assert.Contains(r => r.Name == "System.Runtime", bundle.EntryAnalyzer.AssemblyRefs); bundle.EntryAnalyzer.Dispose(); } @@ -89,14 +92,15 @@ public void OpenSingleFileExe_AssemblyRefs_ContainsSystemRuntime() /// Opens a single-file bundle in the headless TUI, then drills down into a /// referenced assembly to verify that bundle-aware resolution succeeds end-to-end. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task OpenSingleFileExe_DrillDown_SystemRuntime_Succeeds() { - Assert.NotNull(samples.SelfContainedConsoleExe); + Assert.IsNotNull(Samples.SelfContainedConsoleExe); using var cts = CancellationTokenSource.CreateLinkedTokenSource( - TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.SelfContainedConsoleExe!); + CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.SelfContainedConsoleExe!); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -113,7 +117,7 @@ public async Task OpenSingleFileExe_DrillDown_SystemRuntime_Succeeds() .ApplyAsync(terminal, cts.Token); // Verify we navigated — stack should have the original analyzer - Assert.Single(_state!.NavigationStack); + Assert.ContainsSingle(_state!.NavigationStack); cts.Cancel(); await runTask; @@ -123,48 +127,50 @@ public async Task OpenSingleFileExe_DrillDown_SystemRuntime_Succeeds() /// Verifies that hex save is blocked for bundle-backed analyzers, /// no temp file is created, and the bundle file remains unchanged. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void OpenSingleFileExe_HexSave_IsBlocked() { - Assert.NotNull(samples.SelfContainedConsoleExe); + Assert.IsNotNull(Samples.SelfContainedConsoleExe); // Record the bundle's original bytes - var originalBytes = File.ReadAllBytes(samples.SelfContainedConsoleExe!); + var originalBytes = File.ReadAllBytes(Samples.SelfContainedConsoleExe!); var app = new Hex1bApp( _ => Task.FromResult(new TextBlockWidget("test")), new Hex1bAppOptions { WorkloadAdapter = new Hex1bAppWorkloadAdapter() }); - using var state = new DotsiderState(app, samples.SelfContainedConsoleExe!); + using var state = new DotsiderState(app, Samples.SelfContainedConsoleExe!); // Verify the guard blocks save - Assert.True(state.Analyzer.IsBundleBacked); - Assert.False(state.Analyzer.CanSaveInPlace); + Assert.IsTrue(state.Analyzer.IsBundleBacked); + Assert.IsFalse(state.Analyzer.CanSaveInPlace); DotsiderApp.SaveHexChanges(state); - Assert.NotNull(state.HexNotification); + Assert.IsNotNull(state.HexNotification); Assert.Contains("single-file bundle", state.HexNotification!); // No temp file should exist - Assert.False(File.Exists(samples.SelfContainedConsoleExe + ".tmp")); + Assert.IsFalse(File.Exists(Samples.SelfContainedConsoleExe + ".tmp")); // Bundle file must be unchanged - var afterBytes = File.ReadAllBytes(samples.SelfContainedConsoleExe!); - Assert.Equal(originalBytes, afterBytes); + var afterBytes = File.ReadAllBytes(Samples.SelfContainedConsoleExe!); + Assert.AreSequenceEqual(originalBytes, afterBytes); } /// /// Verifies that the for a bundle-backed /// analyzer points to the bundle executable, enabling Dynamic tab tracing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void OpenSingleFileExe_LaunchPath_PointsToBundleExecutable() { - Assert.NotNull(samples.SelfContainedConsoleExe); + Assert.IsNotNull(Samples.SelfContainedConsoleExe); - var result = AssemblyLoader.Open(samples.SelfContainedConsoleExe!); - var bundle = Assert.IsType(result); + var result = AssemblyLoader.Open(Samples.SelfContainedConsoleExe!); + var bundle = Assert.IsExactInstanceOfType(result); - Assert.Equal(samples.SelfContainedConsoleExe, bundle.EntryAnalyzer.LaunchPath); - Assert.True(File.Exists(bundle.EntryAnalyzer.LaunchPath)); + Assert.AreEqual(Samples.SelfContainedConsoleExe, bundle.EntryAnalyzer.LaunchPath); + Assert.IsTrue(File.Exists(bundle.EntryAnalyzer.LaunchPath)); bundle.EntryAnalyzer.Dispose(); } diff --git a/tests/Dotsider.Tests/SingleFileBundleReaderTests.cs b/tests/Dotsider.Tests/SingleFileBundleReaderTests.cs index 2949142d..c24b68c3 100644 --- a/tests/Dotsider.Tests/SingleFileBundleReaderTests.cs +++ b/tests/Dotsider.Tests/SingleFileBundleReaderTests.cs @@ -7,97 +7,108 @@ namespace Dotsider.Tests; /// Tests for covering bundle detection, /// manifest parsing, and entry assembly extraction. /// -[Collection("SampleAssemblies")] -public sealed class SingleFileBundleReaderTests(SampleAssemblyFixture samples) +[TestClass] +public sealed class SingleFileBundleReaderTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// Verifies that a self-contained single-file exe is detected as a bundle. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void IsBundle_SelfContainedExe_ReturnsTrue() { - Assert.NotNull(samples.SelfContainedConsoleExe); - Assert.True(SingleFileBundleReader.IsBundle(samples.SelfContainedConsoleExe!, out var offset)); - Assert.True(offset > 0); + Assert.IsNotNull(Samples.SelfContainedConsoleExe); + Assert.IsTrue(SingleFileBundleReader.IsBundle(Samples.SelfContainedConsoleExe!, out var offset)); + Assert.IsGreaterThan(0, offset); } /// Verifies that a regular managed DLL is not detected as a bundle. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void IsBundle_RegularDll_ReturnsFalse() { - Assert.False(SingleFileBundleReader.IsBundle(samples.RichLibraryDll, out _)); + Assert.IsFalse(SingleFileBundleReader.IsBundle(Samples.RichLibraryDll, out _)); } /// Verifies that a NativeAOT exe is not detected as a bundle. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void IsBundle_NativeAotExe_ReturnsFalse() { - Assert.NotNull(samples.NativeAotConsoleExe); - Assert.False(SingleFileBundleReader.IsBundle(samples.NativeAotConsoleExe!, out _)); + Assert.IsNotNull(Samples.NativeAotConsoleExe); + Assert.IsFalse(SingleFileBundleReader.IsBundle(Samples.NativeAotConsoleExe!, out _)); } /// Verifies that the manifest has a positive file count matching its entries. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadManifest_SelfContainedExe_HasEntries() { - Assert.True(SingleFileBundleReader.IsBundle(samples.SelfContainedConsoleExe!, out var offset)); - var manifest = SingleFileBundleReader.ReadManifest(samples.SelfContainedConsoleExe!, offset); - Assert.True(manifest.FileCount > 0); - Assert.Equal(manifest.FileCount, manifest.Entries.Count); + Assert.IsTrue(SingleFileBundleReader.IsBundle(Samples.SelfContainedConsoleExe!, out var offset)); + var manifest = SingleFileBundleReader.ReadManifest(Samples.SelfContainedConsoleExe!, offset); + Assert.IsGreaterThan(0, manifest.FileCount); + Assert.HasCount(manifest.FileCount, manifest.Entries); } /// Verifies that System.Runtime.dll is included in the bundle. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadManifest_SelfContainedExe_ContainsSystemRuntime() { - Assert.True(SingleFileBundleReader.IsBundle(samples.SelfContainedConsoleExe!, out var offset)); - var manifest = SingleFileBundleReader.ReadManifest(samples.SelfContainedConsoleExe!, offset); - Assert.Contains(manifest.Entries, e => + Assert.IsTrue(SingleFileBundleReader.IsBundle(Samples.SelfContainedConsoleExe!, out var offset)); + var manifest = SingleFileBundleReader.ReadManifest(Samples.SelfContainedConsoleExe!, offset); + Assert.Contains(e => e.Type == BundleFileType.Assembly - && Path.GetFileName(e.RelativePath).Equals("System.Runtime.dll", StringComparison.OrdinalIgnoreCase)); + && Path.GetFileName(e.RelativePath).Equals("System.Runtime.dll", StringComparison.OrdinalIgnoreCase), manifest.Entries); } /// Verifies that the entry assembly DLL is included in the bundle. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadManifest_SelfContainedExe_ContainsEntryAssembly() { - Assert.True(SingleFileBundleReader.IsBundle(samples.SelfContainedConsoleExe!, out var offset)); - var manifest = SingleFileBundleReader.ReadManifest(samples.SelfContainedConsoleExe!, offset); - Assert.Contains(manifest.Entries, e => + Assert.IsTrue(SingleFileBundleReader.IsBundle(Samples.SelfContainedConsoleExe!, out var offset)); + var manifest = SingleFileBundleReader.ReadManifest(Samples.SelfContainedConsoleExe!, offset); + Assert.Contains(e => e.Type == BundleFileType.Assembly - && Path.GetFileName(e.RelativePath).Equals("SelfContainedConsole.dll", StringComparison.OrdinalIgnoreCase)); + && Path.GetFileName(e.RelativePath).Equals("SelfContainedConsole.dll", StringComparison.OrdinalIgnoreCase), manifest.Entries); } /// Verifies that extracted System.Runtime bytes form a valid PE (MZ header). - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ReadAssembly_SystemRuntime_ReturnsPeBytes() { - Assert.True(SingleFileBundleReader.IsBundle(samples.SelfContainedConsoleExe!, out var offset)); - var manifest = SingleFileBundleReader.ReadManifest(samples.SelfContainedConsoleExe!, offset); - var bytes = SingleFileBundleReader.ReadAssembly(samples.SelfContainedConsoleExe!, manifest, "System.Runtime"); - Assert.NotNull(bytes); + Assert.IsTrue(SingleFileBundleReader.IsBundle(Samples.SelfContainedConsoleExe!, out var offset)); + var manifest = SingleFileBundleReader.ReadManifest(Samples.SelfContainedConsoleExe!, offset); + var bytes = SingleFileBundleReader.ReadAssembly(Samples.SelfContainedConsoleExe!, manifest, "System.Runtime"); + Assert.IsNotNull(bytes); // Verify it's a valid PE — MZ header - Assert.True(bytes.Length > 2); - Assert.Equal((byte)'M', bytes[0]); - Assert.Equal((byte)'Z', bytes[1]); + Assert.IsGreaterThan(2, bytes.Length); + Assert.AreEqual((byte)'M', bytes[0]); + Assert.AreEqual((byte)'Z', bytes[1]); } /// Verifies that FindEntryAssembly returns the correct entry name. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindEntryAssembly_SelfContainedExe_MatchesBasename() { - var result = SingleFileBundleReader.FindEntryAssembly(samples.SelfContainedConsoleExe!); - Assert.NotNull(result); - Assert.Equal("SelfContainedConsole.dll", result.Value.Name); - Assert.True(result.Value.Bytes.Length > 0); + var result = SingleFileBundleReader.FindEntryAssembly(Samples.SelfContainedConsoleExe!); + Assert.IsNotNull(result); + Assert.AreEqual("SelfContainedConsole.dll", result.Value.Name); + Assert.IsGreaterThan(0, result.Value.Bytes.Length); } /// Verifies that the extracted entry assembly has valid metadata. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void FindEntryAssembly_SelfContainedExe_HasValidMetadata() { - var result = SingleFileBundleReader.FindEntryAssembly(samples.SelfContainedConsoleExe!); - Assert.NotNull(result); + var result = SingleFileBundleReader.FindEntryAssembly(Samples.SelfContainedConsoleExe!); + Assert.IsNotNull(result); using var analyzer = new AssemblyAnalyzer(result.Value.Bytes, result.Value.Name); - Assert.True(analyzer.HasMetadata); - Assert.Equal("SelfContainedConsole", analyzer.AssemblyName); + Assert.IsTrue(analyzer.HasMetadata); + Assert.AreEqual("SelfContainedConsole", analyzer.AssemblyName); } } diff --git a/tests/Dotsider.Tests/SizeAnalyzerSymbolTests.cs b/tests/Dotsider.Tests/SizeAnalyzerSymbolTests.cs index dbacc9b7..781e95a5 100644 --- a/tests/Dotsider.Tests/SizeAnalyzerSymbolTests.cs +++ b/tests/Dotsider.Tests/SizeAnalyzerSymbolTests.cs @@ -8,9 +8,11 @@ namespace Dotsider.Tests; /// its precedence behind the mstat report — with synthetic merged symbols on every platform and /// the real NativeAOT fixture where its symbol file exists. /// -[Collection("SampleAssemblies")] -public class SizeAnalyzerSymbolTests(SampleAssemblyFixture samples) +[TestClass] +public class SizeAnalyzerSymbolTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private static NativeSymbol Symbol( string name, ulong va, long size, NativeSymbolKind kind, string? managedName = null, bool exact = false) => @@ -27,7 +29,8 @@ private static NativeSymbolInfo Info(params NativeSymbol[] symbols) => /// leaves, including a dotted method name /// (.ctor) split at the recovered type boundary. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void BuildFromSymbols_JoinedFunctions_GroupByAssemblyNamespaceType() { var recovered = new RecoveredType[] @@ -41,31 +44,32 @@ public void BuildFromSymbols_JoinedFunctions_GroupByAssemblyNamespaceType() Symbol("OtherAsm_Ns_Outer_Inner__Run", 0x1050, 0x20, NativeSymbolKind.Function, "Ns.Outer+Inner.Run", exact: true))); var assembly = Find(tree, "TestAsm"); - Assert.NotNull(assembly); - Assert.Equal(SizeNodeKind.Assembly, assembly.Kind); - Assert.Equal(0x50, assembly.Size); + Assert.IsNotNull(assembly); + Assert.AreEqual(SizeNodeKind.Assembly, assembly.Kind); + Assert.AreEqual(0x50, assembly.Size); - var ns = Assert.Single(assembly.Children); - Assert.Equal("System", ns.Name); - Assert.Equal(SizeNodeKind.Namespace, ns.Kind); + var ns = Assert.ContainsSingle(assembly.Children); + Assert.AreEqual("System", ns.Name); + Assert.AreEqual(SizeNodeKind.Namespace, ns.Kind); - var type = Assert.Single(ns.Children); - Assert.Equal("Foo", type.Name); - Assert.Equal(SizeNodeKind.Type, type.Kind); - Assert.Equal(2, type.Children.Count); - Assert.All(type.Children, m => Assert.Equal(SizeNodeKind.Function, m.Kind)); - Assert.Contains(type.Children, m => m.Name == ".ctor" && m.Size == 0x10); + var type = Assert.ContainsSingle(ns.Children); + Assert.AreEqual("Foo", type.Name); + Assert.AreEqual(SizeNodeKind.Type, type.Kind); + Assert.HasCount(2, type.Children); + TestAssert.All(type.Children, m => Assert.AreEqual(SizeNodeKind.Function, m.Kind)); + Assert.Contains(m => m.Name == ".ctor" && m.Size == 0x10, type.Children); var nested = Find(tree, "Outer+Inner"); - Assert.NotNull(nested); - Assert.Equal("Ns", Find(tree, "OtherAsm")!.Children.Single().Name); + Assert.IsNotNull(nested); + Assert.AreEqual("Ns", Find(tree, "OtherAsm")!.Children.Single().Name); } /// /// Verifies the categories: unjoined names in Runtime, boundaries in Unattributed, and each /// data kind under its own category with the matching node kind. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void BuildFromSymbols_Categories_CarryEachKind() { var tree = SizeAnalyzer.BuildFromSymbols("app", [], Info( @@ -81,10 +85,10 @@ public void BuildFromSymbols_Categories_CarryEachKind() void AssertCategory(string name, long size, SizeNodeKind childKind) { var category = Find(tree, name); - Assert.NotNull(category); - Assert.Equal(SizeNodeKind.Category, category.Kind); - Assert.Equal(size, category.Size); - Assert.All(category.Children, c => Assert.Equal(childKind, c.Kind)); + Assert.IsNotNull(category); + Assert.AreEqual(SizeNodeKind.Category, category.Kind); + Assert.AreEqual(size, category.Size); + TestAssert.All(category.Children, c => Assert.AreEqual(childKind, c.Kind)); } AssertCategory("Runtime", 0x30, SizeNodeKind.Function); @@ -96,14 +100,15 @@ void AssertCategory(string name, long size, SizeNodeKind childKind) AssertCategory("Statics", 0x38, SizeNodeKind.Blob); AssertCategory("Data", 0x48, SizeNodeKind.Blob); - Assert.Equal("Widget (MethodTable)", Find(tree, "MethodTables")!.Children.Single().Name); + Assert.AreEqual("Widget (MethodTable)", Find(tree, "MethodTables")!.Children.Single().Name); } /// /// Verifies no byte is counted twice: the root sums every sized merged symbol exactly once, /// and zero-size symbols contribute nothing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void BuildFromSymbols_RootSumsEachSymbolOnce() { var recovered = new RecoveredType[] { new("System.Foo", ["Bar"], "TestAsm") }; @@ -117,8 +122,8 @@ public void BuildFromSymbols_RootSumsEachSymbolOnce() var tree = SizeAnalyzer.BuildFromSymbols("app", recovered, Info(symbols)); - Assert.Equal(0x90, tree.Size); - Assert.Equal(tree.Size, tree.Children.Sum(c => c.Size)); + Assert.AreEqual(0x90, tree.Size); + Assert.AreEqual(tree.Size, tree.Children.Sum(c => c.Size)); } /// @@ -126,36 +131,37 @@ public void BuildFromSymbols_RootSumsEachSymbolOnce() /// (Function leaves, no IL nodes), and with the mstat /// present it keeps precedence (Method leaves appear). /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void BuildSizeTree_MstatPrecedence_SymbolsCarryTheTreeWithoutIt() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat not present"); - Assert.SkipWhen(samples.NativeAotConsoleSymbols is null, "native symbols not present"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat not present"); + TestSkip.When(Samples.NativeAotConsoleSymbols is null, "native symbols not present"); // mstat beside the exe: the report wins. - using (var withMstat = new AssemblyAnalyzer(samples.NativeAotConsoleExe!)) + using (var withMstat = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!)) { var tree = SizeAnalyzer.BuildSizeTree(withMstat); - Assert.Contains(Flatten(tree), n => n.Kind == SizeNodeKind.Method); + Assert.Contains(n => n.Kind == SizeNodeKind.Method, Flatten(tree)); } // Exe and symbols copied away from the mstat: symbols carry the tree. var dir = Directory.CreateTempSubdirectory("dotsider-sizesym-"); try { - var exeCopy = Path.Combine(dir.FullName, Path.GetFileName(samples.NativeAotConsoleExe!)); - File.Copy(samples.NativeAotConsoleExe!, exeCopy); - CopySymbolsBeside(samples.NativeAotConsoleExe!, samples.NativeAotConsoleSymbols!, dir.FullName); + var exeCopy = Path.Combine(dir.FullName, Path.GetFileName(Samples.NativeAotConsoleExe!)); + File.Copy(Samples.NativeAotConsoleExe!, exeCopy); + CopySymbolsBeside(Samples.NativeAotConsoleExe!, Samples.NativeAotConsoleSymbols!, dir.FullName); using var analyzer = new AssemblyAnalyzer(exeCopy); - Assert.Null(analyzer.Mstat); + Assert.IsNull(analyzer.Mstat); var tree = SizeAnalyzer.BuildSizeTree(analyzer); var nodes = Flatten(tree).ToList(); - Assert.Contains(nodes, n => n.Kind == SizeNodeKind.Function); - Assert.DoesNotContain(nodes, n => n.Kind == SizeNodeKind.Method); - Assert.True(tree.Size > 0); + Assert.Contains(n => n.Kind == SizeNodeKind.Function, nodes); + Assert.DoesNotContain(n => n.Kind == SizeNodeKind.Method, nodes); + Assert.IsGreaterThan(0, tree.Size); } finally { diff --git a/tests/Dotsider.Tests/SizeAnalyzerTests.cs b/tests/Dotsider.Tests/SizeAnalyzerTests.cs index d7371808..eb731b2b 100644 --- a/tests/Dotsider.Tests/SizeAnalyzerTests.cs +++ b/tests/Dotsider.Tests/SizeAnalyzerTests.cs @@ -6,150 +6,163 @@ namespace Dotsider.Tests; /// /// Tests for Size Analyzer. /// -[Collection("SampleAssemblies")] -public class SizeAnalyzerTests(SampleAssemblyFixture samples) +[TestClass] +public class SizeAnalyzerTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// Verifies rich library root node is assembly. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_RootNodeIsAssembly() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var tree = SizeAnalyzer.BuildSizeTree(a); - Assert.Equal(SizeNodeKind.Assembly, tree.Kind); + Assert.AreEqual(SizeNodeKind.Assembly, tree.Kind); } /// /// Verifies rich library has namespace children. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_HasNamespaceChildren() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var tree = SizeAnalyzer.BuildSizeTree(a); - Assert.NotEmpty(tree.Children); - Assert.Contains(tree.Children, c => c.Kind == SizeNodeKind.Namespace); + Assert.IsNotEmpty(tree.Children); + Assert.Contains(c => c.Kind == SizeNodeKind.Namespace, tree.Children); } /// /// Verifies rich library namespace has type children. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_NamespaceHasTypeChildren() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var tree = SizeAnalyzer.BuildSizeTree(a); var ns = tree.Children.FirstOrDefault(c => c.Kind == SizeNodeKind.Namespace && c.Children.Count > 0); - Assert.NotNull(ns); - Assert.Contains(ns.Children, c => c.Kind == SizeNodeKind.Type); + Assert.IsNotNull(ns); + Assert.Contains(c => c.Kind == SizeNodeKind.Type, ns.Children); } /// /// Verifies rich library type has method children. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_TypeHasMethodChildren() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var tree = SizeAnalyzer.BuildSizeTree(a); var type = tree.Children .SelectMany(ns => ns.Children) .FirstOrDefault(t => t.Kind == SizeNodeKind.Type && t.Children.Count > 0); - Assert.NotNull(type); - Assert.Contains(type.Children, c => c.Kind == SizeNodeKind.Method); + Assert.IsNotNull(type); + Assert.Contains(c => c.Kind == SizeNodeKind.Method, type.Children); } /// /// Verifies rich library method sizes positive. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_MethodSizesPositive() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var tree = SizeAnalyzer.BuildSizeTree(a); var methods = tree.Children .SelectMany(ns => ns.Children) .SelectMany(t => t.Children) .Where(m => m.Kind == SizeNodeKind.Method) .ToList(); - Assert.NotEmpty(methods); - Assert.All(methods, m => Assert.True(m.Size >= 0)); - Assert.Contains(methods, m => m.Size > 0); + Assert.IsNotEmpty(methods); + TestAssert.All(methods, m => Assert.IsGreaterThanOrEqualTo(0, m.Size)); + Assert.Contains(m => m.Size > 0, methods); } /// /// Verifies hello world simpler tree. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HelloWorld_SimplerTree() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); var tree = SizeAnalyzer.BuildSizeTree(a); - Assert.Equal(SizeNodeKind.Assembly, tree.Kind); - Assert.True(tree.Size > 0); + Assert.AreEqual(SizeNodeKind.Assembly, tree.Kind); + Assert.IsGreaterThan(0, tree.Size); } /// /// Verifies empty lib minimal tree. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void EmptyLib_MinimalTree() { - using var a = new AssemblyAnalyzer(samples.EmptyLibDll); + using var a = new AssemblyAnalyzer(Samples.EmptyLibDll); var tree = SizeAnalyzer.BuildSizeTree(a); - Assert.Equal(SizeNodeKind.Assembly, tree.Kind); + Assert.AreEqual(SizeNodeKind.Assembly, tree.Kind); } /// /// Verifies native lib methods with bodies have size. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeLib_MethodsWithBodiesHaveSize() { - using var a = new AssemblyAnalyzer(samples.NativeLibDll); + using var a = new AssemblyAnalyzer(Samples.NativeLibDll); var tree = SizeAnalyzer.BuildSizeTree(a); - Assert.True(tree.Size > 0); + Assert.IsGreaterThan(0, tree.Size); } /// /// Verifies rich library root size is positive. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_RootSizeIsPositive() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var tree = SizeAnalyzer.BuildSizeTree(a); - Assert.True(tree.Size > 0); + Assert.IsGreaterThan(0, tree.Size); } /// /// Verifies complex app has namespaces. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ComplexApp_HasNamespaces() { - using var a = new AssemblyAnalyzer(samples.ComplexAppDll); + using var a = new AssemblyAnalyzer(Samples.ComplexAppDll); var tree = SizeAnalyzer.BuildSizeTree(a); - Assert.NotEmpty(tree.Children); + Assert.IsNotEmpty(tree.Children); } /// /// Verifies method leaf nodes full path contains token. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void MethodLeafNodes_FullPathContainsToken() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var tree = SizeAnalyzer.BuildSizeTree(a); var methods = tree.Children .SelectMany(ns => ns.Children) .SelectMany(t => t.Children) .Where(m => m.Kind == SizeNodeKind.Method) .ToList(); - Assert.NotEmpty(methods); + Assert.IsNotEmpty(methods); // Every method FullPath should contain :: and @0x for token disambiguation - Assert.All(methods, m => + TestAssert.All(methods, m => { Assert.Contains("::", m.FullPath); Assert.Contains("@0x", m.FullPath); @@ -159,10 +172,11 @@ public void MethodLeafNodes_FullPathContainsToken() /// /// Verifies method leaf nodes tokens are unique. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void MethodLeafNodes_TokensAreUnique() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var tree = SizeAnalyzer.BuildSizeTree(a); var fullPaths = tree.Children .SelectMany(ns => ns.Children) @@ -170,72 +184,75 @@ public void MethodLeafNodes_TokensAreUnique() .Where(m => m.Kind == SizeNodeKind.Method) .Select(m => m.FullPath) .ToList(); - Assert.Equal(fullPaths.Count, fullPaths.Distinct().Count()); + Assert.HasCount(fullPaths.Count, fullPaths.Distinct()); } /// /// Verifies the AOT tree's root holds assembly subtrees beside category buckets when the /// mstat sidecar is present. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeAot_RootHasAssemblyAndCategoryChildren() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - using var a = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var a = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var tree = SizeAnalyzer.BuildSizeTree(a); - Assert.Equal(SizeNodeKind.Assembly, tree.Kind); - Assert.True(tree.Size > 0); - Assert.Contains(tree.Children, c => c.Kind == SizeNodeKind.Assembly && c.Name == "System.Private.CoreLib"); - Assert.Contains(tree.Children, c => c.Kind == SizeNodeKind.Category && c.Name == "Blobs"); - Assert.Contains(tree.Children, c => c.Kind == SizeNodeKind.Category && c.Name == "Frozen Objects"); + Assert.AreEqual(SizeNodeKind.Assembly, tree.Kind); + Assert.IsGreaterThan(0, tree.Size); + Assert.Contains(c => c.Kind == SizeNodeKind.Assembly && c.Name == "System.Private.CoreLib", tree.Children); + Assert.Contains(c => c.Kind == SizeNodeKind.Category && c.Name == "Blobs", tree.Children); + Assert.Contains(c => c.Kind == SizeNodeKind.Category && c.Name == "Frozen Objects", tree.Children); } /// /// Verifies assembly subtrees nest namespace > type > leaf, and leaves carry the /// dependency-graph node name that powers why-chains. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeAot_AssemblySubtreesNestToLeavesWithNodeNames() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - using var a = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var a = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var tree = SizeAnalyzer.BuildSizeTree(a); var assembly = tree.Children.First(c => c.Kind == SizeNodeKind.Assembly && c.Name == "System.Private.CoreLib"); var ns = assembly.Children[0]; - Assert.Equal(SizeNodeKind.Namespace, ns.Kind); + Assert.AreEqual(SizeNodeKind.Namespace, ns.Kind); var type = ns.Children[0]; - Assert.Equal(SizeNodeKind.Type, type.Kind); + Assert.AreEqual(SizeNodeKind.Type, type.Kind); var leaves = type.Children; - Assert.NotEmpty(leaves); - Assert.All(leaves, l => Assert.True(l.Kind is SizeNodeKind.Method or SizeNodeKind.MethodTable)); - Assert.Contains(leaves, l => l.AotNodeName is not null); + Assert.IsNotEmpty(leaves); + TestAssert.All(leaves, l => Assert.IsTrue(l.Kind is SizeNodeKind.Method or SizeNodeKind.MethodTable)); + Assert.Contains(l => l.AotNodeName is not null, leaves); } /// /// Verifies sizes sum exactly at every level of an assembly subtree — the MethodTable /// leaf exists precisely so nothing is attributed to a parent without a child to show it. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeAot_SizesSumExactlyThroughTheTree() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - using var a = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var a = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var tree = SizeAnalyzer.BuildSizeTree(a); - Assert.Equal(tree.Children.Sum(c => c.Size), tree.Size); + Assert.AreEqual(tree.Children.Sum(c => c.Size), tree.Size); foreach (var assembly in tree.Children.Where(c => c.Kind == SizeNodeKind.Assembly)) { - Assert.Equal(assembly.Children.Sum(n => n.Size), assembly.Size); + Assert.AreEqual(assembly.Children.Sum(n => n.Size), assembly.Size); foreach (var ns in assembly.Children) { - Assert.Equal(ns.Children.Sum(t => t.Size), ns.Size); + Assert.AreEqual(ns.Children.Sum(t => t.Size), ns.Size); foreach (var type in ns.Children) - Assert.Equal(type.Children.Sum(m => m.Size), type.Size); + Assert.AreEqual(type.Children.Sum(m => m.Size), type.Size); } } } @@ -244,22 +261,23 @@ public void NativeAot_SizesSumExactlyThroughTheTree() /// Verifies the double-count guard: the blob buckets that 2.1+ re-reports as detail /// sections are excluded from the Blobs category when those sections have entries. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeAot_CategoryBucketsExcludeDoubleCountedBlobs() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - using var a = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); - Assert.NotNull(a.Mstat); - Assert.NotEmpty(a.Mstat.FrozenObjects); + using var a = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); + Assert.IsNotNull(a.Mstat); + Assert.IsNotEmpty(a.Mstat.FrozenObjects); var tree = SizeAnalyzer.BuildSizeTree(a); var blobs = tree.Children.First(c => c.Name == "Blobs"); - Assert.DoesNotContain(blobs.Children, b => b.Name == "ArrayOfFrozenObjects"); - Assert.DoesNotContain(blobs.Children, b => b.Name == "FieldRvaData"); - Assert.Contains(tree.Children, c => c.Name == "Frozen Objects"); - Assert.Contains(tree.Children, c => c.Name == "RVA Fields"); + Assert.DoesNotContain(b => b.Name == "ArrayOfFrozenObjects", blobs.Children); + Assert.DoesNotContain(b => b.Name == "FieldRvaData", blobs.Children); + Assert.Contains(c => c.Name == "Frozen Objects", tree.Children); + Assert.Contains(c => c.Name == "RVA Fields", tree.Children); } /// @@ -267,23 +285,24 @@ public void NativeAot_CategoryBucketsExcludeDoubleCountedBlobs() /// unwind-data boundaries fill it at symbol fidelity (no IL /// nodes), instead of the pre-symbol empty tree. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeAot_WithoutSidecar_BuildsTreeFromBoundaries() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); var dir = Directory.CreateTempSubdirectory("dotsider-sizemap-"); try { - var exeCopy = Path.Combine(dir.FullName, Path.GetFileName(samples.NativeAotConsoleExe!)); - File.Copy(samples.NativeAotConsoleExe!, exeCopy); + var exeCopy = Path.Combine(dir.FullName, Path.GetFileName(Samples.NativeAotConsoleExe!)); + File.Copy(Samples.NativeAotConsoleExe!, exeCopy); using var a = new AssemblyAnalyzer(exeCopy); var tree = SizeAnalyzer.BuildSizeTree(a); - Assert.True(tree.Size > 0); - Assert.NotEmpty(tree.Children); - Assert.DoesNotContain(tree.Children, c => c.Kind == SizeNodeKind.Method); + Assert.IsGreaterThan(0, tree.Size); + Assert.IsNotEmpty(tree.Children); + Assert.DoesNotContain(c => c.Kind == SizeNodeKind.Method, tree.Children); } finally { @@ -295,16 +314,17 @@ public void NativeAot_WithoutSidecar_BuildsTreeFromBoundaries() /// Verifies managed trees are unchanged by the AOT additions: no node carries an AOT /// node name, so serialized output stays identical. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Managed_TreeCarriesNoAotNodeNames() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var tree = SizeAnalyzer.BuildSizeTree(a); var all = new List(); void Walk(SizeNode n) { all.Add(n); foreach (var c in n.Children) Walk(c); } Walk(tree); - Assert.All(all, n => Assert.Null(n.AotNodeName)); + TestAssert.All(all, n => Assert.IsNull(n.AotNodeName)); } } diff --git a/tests/Dotsider.Tests/SizeBudgetEvaluatorTests.cs b/tests/Dotsider.Tests/SizeBudgetEvaluatorTests.cs index 28ebb5eb..ed35d21c 100644 --- a/tests/Dotsider.Tests/SizeBudgetEvaluatorTests.cs +++ b/tests/Dotsider.Tests/SizeBudgetEvaluatorTests.cs @@ -7,17 +7,19 @@ namespace Dotsider.Tests; /// Tests for against the real V1/V2 size diff. Expected /// values are recomputed from the same diff the evaluator sees — never golden byte counts. /// -[Collection("SampleAssemblies")] -public class SizeBudgetEvaluatorTests(SampleAssemblyFixture samples) +[TestClass] +public class SizeBudgetEvaluatorTests { - private MstatDiffResult DiffV1V2() + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + + private static MstatDiffResult DiffV1V2() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "V1 mstat sidecar was not produced"); - Assert.SkipWhen(samples.NativeAotConsoleV2Mstat is null, "V2 mstat sidecar was not produced"); - var v1 = MstatReader.Read(samples.NativeAotConsoleMstat!); - var v2 = MstatReader.Read(samples.NativeAotConsoleV2Mstat!); - Assert.NotNull(v1); - Assert.NotNull(v2); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "V1 mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleV2Mstat is null, "V2 mstat sidecar was not produced"); + var v1 = MstatReader.Read(Samples.NativeAotConsoleMstat!); + var v2 = MstatReader.Read(Samples.NativeAotConsoleV2Mstat!); + Assert.IsNotNull(v1); + Assert.IsNotNull(v2); return MstatDiffer.Compare(v1, v2); } @@ -31,74 +33,79 @@ [.. specs.Select(SizeBudgetParser.Parse)], diff, /// Verifies a zero-growth budget on the namespace added in V2 fails, and the report /// fails with it. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Evaluate_TelemetryZeroGrowthBudget_Fails() { var diff = DiffV1V2(); var report = Evaluate(diff, "ns=NativeAotConsole.Telemetry:growth=0"); - Assert.False(report.Passed); - var evaluation = Assert.Single(report.Evaluations); - Assert.False(evaluation.Passed); - var violation = Assert.Single(evaluation.Violations); - Assert.Equal(SizeBudgetMetric.MaxGrowthBytes, violation.Metric); - Assert.True(violation.OverageBytes > 0); - Assert.Equal(SizeBasis.MstatTotal, evaluation.Basis); + Assert.IsFalse(report.Passed); + var evaluation = Assert.ContainsSingle(report.Evaluations); + Assert.IsFalse(evaluation.Passed); + var violation = Assert.ContainsSingle(evaluation.Violations); + Assert.AreEqual(SizeBudgetMetric.MaxGrowthBytes, violation.Metric); + Assert.IsGreaterThan(0, violation.OverageBytes); + Assert.AreEqual(SizeBasis.MstatTotal, evaluation.Basis); } /// Verifies a generous total percentage budget passes on the real growth. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Evaluate_GenerousTotalPercent_Passes() { var diff = DiffV1V2(); var report = Evaluate(diff, "total:growth=1000%"); - Assert.True(report.Passed); - Assert.True(Assert.Single(report.Evaluations).Passed); + Assert.IsTrue(report.Passed); + Assert.IsTrue(Assert.ContainsSingle(report.Evaluations).Passed); } /// Verifies a self-diff passes a zero-growth total budget. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Evaluate_SelfDiffZeroGrowth_Passes() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "V1 mstat sidecar was not produced"); - var v1 = MstatReader.Read(samples.NativeAotConsoleMstat!); - Assert.NotNull(v1); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "V1 mstat sidecar was not produced"); + var v1 = MstatReader.Read(Samples.NativeAotConsoleMstat!); + Assert.IsNotNull(v1); var diff = MstatDiffer.Compare(v1, v1); var report = Evaluate(diff, "total:growth=0"); - Assert.True(report.Passed); + Assert.IsTrue(report.Passed); } /// /// Verifies the percentage math is computed against the baseline: the expected violation /// figures are recomputed here from the same totals. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Evaluate_PercentComputedAgainstBaseline() { var diff = DiffV1V2(); var growth = diff.Summary.RightTotal - diff.Summary.LeftTotal; - Assert.SkipWhen(growth <= 0, "V2 did not grow relative to V1"); + TestSkip.When(growth <= 0, "V2 did not grow relative to V1"); var report = Evaluate(diff, "total:growth=0.001%"); - var violation = Assert.Single(Assert.Single(report.Evaluations).Violations); - Assert.Equal(SizeBudgetMetric.MaxGrowthPercent, violation.Metric); - Assert.Equal(growth, violation.ActualBytes); - Assert.Equal((long)(diff.Summary.LeftTotal * 0.00001), violation.LimitBytes); - Assert.NotNull(violation.ActualPercent); - Assert.Equal(100.0 * growth / diff.Summary.LeftTotal, violation.ActualPercent!.Value, 3); + var violation = Assert.ContainsSingle(Assert.ContainsSingle(report.Evaluations).Violations); + Assert.AreEqual(SizeBudgetMetric.MaxGrowthPercent, violation.Metric); + Assert.AreEqual(growth, violation.ActualBytes); + Assert.AreEqual((long)(diff.Summary.LeftTotal * 0.00001), violation.LimitBytes); + Assert.IsNotNull(violation.ActualPercent); + Assert.AreEqual(100.0 * growth / diff.Summary.LeftTotal, violation.ActualPercent!.Value, 3); } /// /// Verifies namespace prefix semantics: the parent namespace's budget covers the fixture's /// added child namespace, while a lookalike sibling prefix covers nothing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Evaluate_NamespacePrefixCoversChildNotSibling() { var diff = DiffV1V2(); @@ -108,17 +115,18 @@ public void Evaluate_NamespacePrefixCoversChildNotSibling() "ns=NativeAotConsole:growth=0", // covers NativeAotConsole.Telemetry "ns=NativeAotConsole.Tele:growth=0"); // a prefix, not a namespace — covers nothing - Assert.False(report.Evaluations[0].Passed); - Assert.True(report.Evaluations[0].ActualBytes > 0); - Assert.True(report.Evaluations[1].Passed); - Assert.Equal(0, report.Evaluations[1].ActualBytes); + Assert.IsFalse(report.Evaluations[0].Passed); + Assert.IsGreaterThan(0, report.Evaluations[0].ActualBytes); + Assert.IsTrue(report.Evaluations[1].Passed); + Assert.AreEqual(0, report.Evaluations[1].ActualBytes); } /// /// Verifies the assembly scope measures the app assembly's aggregate, recomputed from the /// same diff. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Evaluate_AssemblyScopeMeasuresAggregate() { var diff = DiffV1V2(); @@ -126,69 +134,72 @@ public void Evaluate_AssemblyScopeMeasuresAggregate() var report = Evaluate(diff, "asm=NativeAotConsole:growth=0"); - var evaluation = Assert.Single(report.Evaluations); - Assert.Equal(expected.RightSize, evaluation.ActualBytes); - Assert.Equal(expected.LeftSize, evaluation.BaselineBytes); - Assert.False(evaluation.Passed); + var evaluation = Assert.ContainsSingle(report.Evaluations); + Assert.AreEqual(expected.RightSize, evaluation.ActualBytes); + Assert.AreEqual(expected.LeftSize, evaluation.BaselineBytes); + Assert.IsFalse(evaluation.Passed); } /// /// Verifies a breach's contributors are positive regressions only, inside the budget's /// scope, ordered by delta — improvements never crowd out the rows explaining growth. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Evaluate_ViolationCarriesOrderedScopedContributors() { var diff = DiffV1V2(); var report = Evaluate(diff, "ns=NativeAotConsole.Telemetry:growth=0"); - var contributors = Assert.Single(report.Evaluations).TopContributors; - Assert.NotEmpty(contributors); - Assert.All(contributors, c => + var contributors = Assert.ContainsSingle(report.Evaluations).TopContributors; + Assert.IsNotEmpty(contributors); + TestAssert.All(contributors, c => { - Assert.True(c.Delta > 0); + Assert.IsGreaterThan(0, c.Delta); Assert.StartsWith("NativeAotConsole.Telemetry", c.Namespace, StringComparison.Ordinal); }); for (var i = 1; i < contributors.Count; i++) - Assert.True(contributors[i - 1].Delta >= contributors[i].Delta); + Assert.IsGreaterThanOrEqualTo(contributors[i].Delta, contributors[i - 1].Delta); } /// /// Verifies an absolute budget evaluates without a baseline against the empty report: /// everything is added and the actual equals the build's total. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Evaluate_MaxBudgetWithoutBaseline_UsesEmptyLeft() { - Assert.SkipWhen(samples.NativeAotConsoleV2Mstat is null, "V2 mstat sidecar was not produced"); - var v2 = MstatReader.Read(samples.NativeAotConsoleV2Mstat!); - Assert.NotNull(v2); + TestSkip.When(Samples.NativeAotConsoleV2Mstat is null, "V2 mstat sidecar was not produced"); + var v2 = MstatReader.Read(Samples.NativeAotConsoleV2Mstat!); + Assert.IsNotNull(v2); var diff = MstatDiffer.Compare(MstatData.Empty, v2); var report = SizeBudgetEvaluator.Evaluate( [SizeBudgetParser.Parse("max=1b")], diff, SizeBasis.MstatTotal, diff.Summary.RightTotal, baselineTotalBytes: null); - Assert.False(report.Passed); - var violation = Assert.Single(Assert.Single(report.Evaluations).Violations); - Assert.Equal(SizeBudgetMetric.MaxBytes, violation.Metric); - Assert.Equal(diff.Summary.RightTotal, violation.ActualBytes); + Assert.IsFalse(report.Passed); + var violation = Assert.ContainsSingle(Assert.ContainsSingle(report.Evaluations).Violations); + Assert.AreEqual(SizeBudgetMetric.MaxBytes, violation.Metric); + Assert.AreEqual(diff.Summary.RightTotal, violation.ActualBytes); } /// /// Verifies a total-scope growth budget without a baseline is rejected loudly — callers /// must reject it upstream, and the evaluator refuses to guess. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Evaluate_GrowthWithoutBaseline_Throws() { - Assert.SkipWhen(samples.NativeAotConsoleV2Mstat is null, "V2 mstat sidecar was not produced"); - var v2 = MstatReader.Read(samples.NativeAotConsoleV2Mstat!); - Assert.NotNull(v2); + TestSkip.When(Samples.NativeAotConsoleV2Mstat is null, "V2 mstat sidecar was not produced"); + var v2 = MstatReader.Read(Samples.NativeAotConsoleV2Mstat!); + Assert.IsNotNull(v2); var diff = MstatDiffer.Compare(MstatData.Empty, v2); - Assert.Throws(() => SizeBudgetEvaluator.Evaluate( + Assert.ThrowsExactly(() => SizeBudgetEvaluator.Evaluate( [SizeBudgetParser.Parse("total:growth=1%")], diff, SizeBasis.MstatTotal, diff.Summary.RightTotal, baselineTotalBytes: null)); } @@ -197,7 +208,8 @@ public void Evaluate_GrowthWithoutBaseline_Throws() /// Verifies warning severity reports the breach without failing the check, and the /// warning surfaces through . /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Evaluate_WarningSeverity_ReportsWithoutFailing() { var diff = DiffV1V2(); @@ -208,16 +220,17 @@ public void Evaluate_WarningSeverity_ReportsWithoutFailing() [warning], diff, SizeBasis.MstatTotal, diff.Summary.RightTotal, diff.Summary.LeftTotal); - Assert.True(report.Passed); - Assert.True(report.HasWarnings); - Assert.False(Assert.Single(report.Evaluations).Passed); + Assert.IsTrue(report.Passed); + Assert.IsTrue(report.HasWarnings); + Assert.IsFalse(Assert.ContainsSingle(report.Evaluations).Passed); } /// /// Verifies the report surfaces the basis and both bases' totals when the check ran on /// file sizes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Evaluate_FileSizeBasis_SurfacesBothBases() { var diff = DiffV1V2(); @@ -226,11 +239,11 @@ public void Evaluate_FileSizeBasis_SurfacesBothBases() [SizeBudgetParser.Parse("max=10gb")], diff, SizeBasis.FileSize, currentTotalBytes: 123_456, baselineTotalBytes: 100_000); - Assert.Equal(SizeBasis.FileSize, report.TotalBasis); - Assert.Equal(123_456, report.RightTotal); - Assert.Equal(100_000, report.LeftTotal); - Assert.Equal(diff.Summary.LeftTotal, report.LeftMstatTotal); - Assert.Equal(diff.Summary.RightTotal, report.RightMstatTotal); - Assert.Equal(SizeBasis.FileSize, Assert.Single(report.Evaluations).Basis); + Assert.AreEqual(SizeBasis.FileSize, report.TotalBasis); + Assert.AreEqual(123_456, report.RightTotal); + Assert.AreEqual(100_000, report.LeftTotal); + Assert.AreEqual(diff.Summary.LeftTotal, report.LeftMstatTotal); + Assert.AreEqual(diff.Summary.RightTotal, report.RightMstatTotal); + Assert.AreEqual(SizeBasis.FileSize, Assert.ContainsSingle(report.Evaluations).Basis); } } diff --git a/tests/Dotsider.Tests/SizeBudgetFileTests.cs b/tests/Dotsider.Tests/SizeBudgetFileTests.cs index 6c687077..044e08de 100644 --- a/tests/Dotsider.Tests/SizeBudgetFileTests.cs +++ b/tests/Dotsider.Tests/SizeBudgetFileTests.cs @@ -8,22 +8,25 @@ namespace Dotsider.Tests; /// severity, and precise failures on malformed documents. Pure input-domain tests — the /// parser takes JSON text, so JSON text is the real fixture. /// +[TestClass] public class SizeBudgetFileTests { /// Verifies string entries parse through the spec grammar. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Parse_StringEntries_UseSpecGrammar() { var budgets = SizeBudgetFile.Parse( """{ "budgets": ["total:max=25mb", "ns=System.Text.Json:growth=10kb"] }"""); - Assert.Equal(2, budgets.Count); - Assert.Equal(SizeBudgetScope.Total, budgets[0].Scope); - Assert.Equal("System.Text.Json", budgets[1].Target); + Assert.HasCount(2, budgets); + Assert.AreEqual(SizeBudgetScope.Total, budgets[0].Scope); + Assert.AreEqual("System.Text.Json", budgets[1].Target); } /// Verifies the object form carries name, description, severity, and topN. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Parse_ObjectEntry_CarriesNameSeverityTopN() { var budgets = SizeBudgetFile.Parse(""" @@ -37,18 +40,19 @@ public void Parse_ObjectEntry_CarriesNameSeverityTopN() } ] } """); - var budget = Assert.Single(budgets); - Assert.Equal("JSON growth", budget.Name); - Assert.Equal("Serializer bloat guard", budget.Description); - Assert.Equal(SizeBudgetScope.Namespace, budget.Scope); - Assert.Equal("System.Text.Json", budget.Target); - Assert.Equal(10L * 1024, budget.MaxGrowthBytes); - Assert.Equal(SizeBudgetSeverity.Warning, budget.Severity); - Assert.Equal(5, budget.TopN); + var budget = Assert.ContainsSingle(budgets); + Assert.AreEqual("JSON growth", budget.Name); + Assert.AreEqual("Serializer bloat guard", budget.Description); + Assert.AreEqual(SizeBudgetScope.Namespace, budget.Scope); + Assert.AreEqual("System.Text.Json", budget.Target); + Assert.AreEqual(10L * 1024, budget.MaxGrowthBytes); + Assert.AreEqual(SizeBudgetSeverity.Warning, budget.Severity); + Assert.AreEqual(5, budget.TopN); } /// Verifies string and object entries mix freely in one document. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Parse_MixedEntries_ParseInOrder() { var budgets = SizeBudgetFile.Parse(""" @@ -58,34 +62,36 @@ public void Parse_MixedEntries_ParseInOrder() ] } """); - Assert.Equal(2, budgets.Count); - Assert.Equal(SizeBudgetScope.Total, budgets[0].Scope); - Assert.Equal(SizeBudgetScope.Assembly, budgets[1].Scope); - Assert.Equal("MyApp", budgets[1].Target); + Assert.HasCount(2, budgets); + Assert.AreEqual(SizeBudgetScope.Total, budgets[0].Scope); + Assert.AreEqual(SizeBudgetScope.Assembly, budgets[1].Scope); + Assert.AreEqual("MyApp", budgets[1].Target); } /// Verifies an object entry without a scope defaults to total. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Parse_ObjectWithoutScope_DefaultsToTotal() { var budgets = SizeBudgetFile.Parse("""{ "budgets": [ { "max": "1mb" } ] }"""); - Assert.Equal(SizeBudgetScope.Total, Assert.Single(budgets).Scope); + Assert.AreEqual(SizeBudgetScope.Total, Assert.ContainsSingle(budgets).Scope); } /// Verifies every malformed document fails with a message locating the problem. - [Theory(Timeout = 30_000)] - [InlineData("not json", "not valid JSON")] - [InlineData("""{ "nope": [] }""", "'budgets' array")] - [InlineData("""{ "budgets": [42] }""", "budgets[0]")] - [InlineData("""{ "budgets": [ {} ] }""", "'max' and/or 'growth'")] - [InlineData("""{ "budgets": [ { "max": "1kb", "severity": "fatal" } ] }""", "severity")] - [InlineData("""{ "budgets": [ { "max": "1kb", "topN": -1 } ] }""", "topN")] - [InlineData("""{ "budgets": [ { "max": "1kb", "unknown": true } ] }""", "unknown property")] - [InlineData("""{ "budgets": [ { "max": "zzz" } ] }""", "not a valid size")] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow("not json", "not valid JSON")] + [DataRow("""{ "nope": [] }""", "'budgets' array")] + [DataRow("""{ "budgets": [42] }""", "budgets[0]")] + [DataRow("""{ "budgets": [ {} ] }""", "'max' and/or 'growth'")] + [DataRow("""{ "budgets": [ { "max": "1kb", "severity": "fatal" } ] }""", "severity")] + [DataRow("""{ "budgets": [ { "max": "1kb", "topN": -1 } ] }""", "topN")] + [DataRow("""{ "budgets": [ { "max": "1kb", "unknown": true } ] }""", "unknown property")] + [DataRow("""{ "budgets": [ { "max": "zzz" } ] }""", "not a valid size")] public void Parse_InvalidDocument_ThrowsWithContext(string json, string messagePart) { - var ex = Assert.Throws(() => SizeBudgetFile.Parse(json)); + var ex = Assert.ThrowsExactly(() => SizeBudgetFile.Parse(json)); Assert.Contains(messagePart, ex.Message, StringComparison.OrdinalIgnoreCase); } } diff --git a/tests/Dotsider.Tests/SizeBudgetParserTests.cs b/tests/Dotsider.Tests/SizeBudgetParserTests.cs index 59e9c562..b63a399b 100644 --- a/tests/Dotsider.Tests/SizeBudgetParserTests.cs +++ b/tests/Dotsider.Tests/SizeBudgetParserTests.cs @@ -7,129 +7,140 @@ namespace Dotsider.Tests; /// Tests for the size-budget spec grammar. These are pure input-domain tests — the grammar /// takes strings, so strings are the real fixtures. /// +[TestClass] public class SizeBudgetParserTests { /// Verifies a bare limit defaults to the total scope. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Parse_BareMax_DefaultsToTotalScope() { var budget = SizeBudgetParser.Parse("max=25mb"); - Assert.Equal(SizeBudgetScope.Total, budget.Scope); - Assert.Null(budget.Target); - Assert.Equal(25L * 1024 * 1024, budget.MaxBytes); - Assert.Null(budget.MaxGrowthBytes); - Assert.Null(budget.MaxGrowthPercent); - Assert.Equal(SizeBudgetSeverity.Error, budget.Severity); + Assert.AreEqual(SizeBudgetScope.Total, budget.Scope); + Assert.IsNull(budget.Target); + Assert.AreEqual(25L * 1024 * 1024, budget.MaxBytes); + Assert.IsNull(budget.MaxGrowthBytes); + Assert.IsNull(budget.MaxGrowthPercent); + Assert.AreEqual(SizeBudgetSeverity.Error, budget.Severity); } /// Verifies percentage growth parses on the total scope. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Parse_GrowthPercent_Parses() { var budget = SizeBudgetParser.Parse("growth=1.5%"); - Assert.Equal(SizeBudgetScope.Total, budget.Scope); - Assert.Equal(1.5, budget.MaxGrowthPercent); - Assert.Null(budget.MaxGrowthBytes); + Assert.AreEqual(SizeBudgetScope.Total, budget.Scope); + Assert.AreEqual(1.5, budget.MaxGrowthPercent); + Assert.IsNull(budget.MaxGrowthBytes); } /// Verifies an explicit total scope with multiple limits parses both. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Parse_TotalScopeMultiLimit_ParsesBoth() { var budget = SizeBudgetParser.Parse("total:max=25mb,growth=50kb"); - Assert.Equal(SizeBudgetScope.Total, budget.Scope); - Assert.Equal(25L * 1024 * 1024, budget.MaxBytes); - Assert.Equal(50L * 1024, budget.MaxGrowthBytes); + Assert.AreEqual(SizeBudgetScope.Total, budget.Scope); + Assert.AreEqual(25L * 1024 * 1024, budget.MaxBytes); + Assert.AreEqual(50L * 1024, budget.MaxGrowthBytes); } /// Verifies a namespace scope keeps its dotted target intact. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Parse_NamespaceScope_KeepsTarget() { var budget = SizeBudgetParser.Parse("ns=System.Text.Json:growth=10kb"); - Assert.Equal(SizeBudgetScope.Namespace, budget.Scope); - Assert.Equal("System.Text.Json", budget.Target); - Assert.Equal(10L * 1024, budget.MaxGrowthBytes); + Assert.AreEqual(SizeBudgetScope.Namespace, budget.Scope); + Assert.AreEqual("System.Text.Json", budget.Target); + Assert.AreEqual(10L * 1024, budget.MaxGrowthBytes); } /// Verifies an assembly scope parses with mixed limits. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Parse_AssemblyScope_ParsesMixedLimits() { var budget = SizeBudgetParser.Parse("asm=MyApp:max=2mb,growth=5%"); - Assert.Equal(SizeBudgetScope.Assembly, budget.Scope); - Assert.Equal("MyApp", budget.Target); - Assert.Equal(2L * 1024 * 1024, budget.MaxBytes); - Assert.Equal(5.0, budget.MaxGrowthPercent); + Assert.AreEqual(SizeBudgetScope.Assembly, budget.Scope); + Assert.AreEqual("MyApp", budget.Target); + Assert.AreEqual(2L * 1024 * 1024, budget.MaxBytes); + Assert.AreEqual(5.0, budget.MaxGrowthPercent); } /// Verifies unit handling: binary units, explicit bytes, and bare numbers. - [Theory(Timeout = 30_000)] - [InlineData("max=4096", 4096L)] - [InlineData("max=4096b", 4096L)] - [InlineData("max=1kb", 1024L)] - [InlineData("max=1KB", 1024L)] - [InlineData("max=1.5kb", 1536L)] - [InlineData("max=1gb", 1024L * 1024 * 1024)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow("max=4096", 4096L)] + [DataRow("max=4096b", 4096L)] + [DataRow("max=1kb", 1024L)] + [DataRow("max=1KB", 1024L)] + [DataRow("max=1.5kb", 1536L)] + [DataRow("max=1gb", 1024L * 1024 * 1024)] public void Parse_SizeUnits_ResolveToBytes(string spec, long expected) { - Assert.Equal(expected, SizeBudgetParser.Parse(spec).MaxBytes); + Assert.AreEqual(expected, SizeBudgetParser.Parse(spec).MaxBytes); } /// Verifies zero growth parses — the "no growth at all" gate. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Parse_ZeroGrowth_Parses() { var budget = SizeBudgetParser.Parse("ns=NativeAotConsole.Telemetry:growth=0"); - Assert.Equal(0L, budget.MaxGrowthBytes); + Assert.AreEqual(0L, budget.MaxGrowthBytes); } /// Verifies scope and keyword casing are forgiven. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Parse_CaseInsensitiveKeywords_Parse() { var budget = SizeBudgetParser.Parse("NS=Foo:GROWTH=10KB"); - Assert.Equal(SizeBudgetScope.Namespace, budget.Scope); - Assert.Equal("Foo", budget.Target); - Assert.Equal(10L * 1024, budget.MaxGrowthBytes); + Assert.AreEqual(SizeBudgetScope.Namespace, budget.Scope); + Assert.AreEqual("Foo", budget.Target); + Assert.AreEqual(10L * 1024, budget.MaxGrowthBytes); } /// Verifies every malformed spec fails with a message naming the offending part. - [Theory(Timeout = 30_000)] - [InlineData("", "empty")] - [InlineData(" ", "empty")] - [InlineData("total", "limit")] - [InlineData("ns=Foo", "limit")] - [InlineData("total:", "limit")] - [InlineData("ns=:growth=1kb", "namespace")] - [InlineData("asm=:max=1kb", "assembly")] - [InlineData("weird=Foo:max=1kb", "unknown scope")] - [InlineData("cap=25mb", "max=SIZE or growth=")] - [InlineData("max=25zb", "not a valid size")] - [InlineData("max=abc", "not a valid size")] - [InlineData("max=-5", "not a valid size")] - [InlineData("max=5%", "growth")] - [InlineData("max=1kb,max=2kb", "duplicate")] - [InlineData("growth=1%,growth=2%", "duplicate")] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow("", "empty")] + [DataRow(" ", "empty")] + [DataRow("total", "limit")] + [DataRow("ns=Foo", "limit")] + [DataRow("total:", "limit")] + [DataRow("ns=:growth=1kb", "namespace")] + [DataRow("asm=:max=1kb", "assembly")] + [DataRow("weird=Foo:max=1kb", "unknown scope")] + [DataRow("cap=25mb", "max=SIZE or growth=")] + [DataRow("max=25zb", "not a valid size")] + [DataRow("max=abc", "not a valid size")] + [DataRow("max=-5", "not a valid size")] + [DataRow("max=5%", "growth")] + [DataRow("max=1kb,max=2kb", "duplicate")] + [DataRow("growth=1%,growth=2%", "duplicate")] public void Parse_InvalidSpec_ThrowsWithContext(string spec, string messagePart) { - var ex = Assert.Throws(() => SizeBudgetParser.Parse(spec)); + var ex = Assert.ThrowsExactly(() => SizeBudgetParser.Parse(spec)); Assert.Contains(messagePart, ex.Message, StringComparison.OrdinalIgnoreCase); } /// Verifies a parsed budget renders back into grammar form for display. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ToString_RendersSpecForm() { - Assert.Equal("ns=Foo:growth=10240", + Assert.AreEqual("ns=Foo:growth=10240", SizeBudgetParser.Parse("ns=Foo:growth=10kb").ToString()); - Assert.Equal("total:max=1024", SizeBudgetParser.Parse("max=1kb").ToString()); + Assert.AreEqual("total:max=1024", SizeBudgetParser.Parse("max=1kb").ToString()); } } diff --git a/tests/Dotsider.Tests/SizeCheckCliTests.cs b/tests/Dotsider.Tests/SizeCheckCliTests.cs index 2d9a9d0a..adc3e3cb 100644 --- a/tests/Dotsider.Tests/SizeCheckCliTests.cs +++ b/tests/Dotsider.Tests/SizeCheckCliTests.cs @@ -7,18 +7,21 @@ namespace Dotsider.Tests; /// dotsider diff, asserting exit codes (0 pass, 1 error, 2 budget exceeded), stream /// content, and the JSON document shape against the real V1/V2 published pair. /// -[Collection("SampleAssemblies")] -public class SizeCheckCliTests(SampleAssemblyFixture fixture) +[TestClass] +public class SizeCheckCliTests { - private (string V1, string V2) RequireMstats() + private static SampleAssemblyFixture Fixture => SampleAssemblyHost.Instance; + + private static (string V1, string V2) RequireMstats() { - Assert.SkipWhen(fixture.NativeAotConsoleMstat is null, "V1 mstat sidecar was not produced"); - Assert.SkipWhen(fixture.NativeAotConsoleV2Mstat is null, "V2 mstat sidecar was not produced"); - return (fixture.NativeAotConsoleMstat!, fixture.NativeAotConsoleV2Mstat!); + TestSkip.When(Fixture.NativeAotConsoleMstat is null, "V1 mstat sidecar was not produced"); + TestSkip.When(Fixture.NativeAotConsoleV2Mstat is null, "V2 mstat sidecar was not produced"); + return (Fixture.NativeAotConsoleMstat!, Fixture.NativeAotConsoleV2Mstat!); } /// Verifies a self-baseline report succeeds with exit 0 and prints the basis. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeCheck_SelfBaseline_Exit0() { var (v1, _) = RequireMstats(); @@ -26,7 +29,7 @@ public async Task SizeCheck_SelfBaseline_Exit0() var (exitCode, stdout, _) = await TestHelpers.RunDotsiderAsync( "size-check", v1, "--baseline", v1); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("Basis: mstatTotal", stdout); Assert.Contains("Formats: 2.2 -> 2.2", stdout); } @@ -35,7 +38,8 @@ public async Task SizeCheck_SelfBaseline_Exit0() /// Verifies a zero-growth budget on the namespace added in V2 exits 2 and prints the /// namespace's own members as the top contributors. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeCheck_TelemetryZeroGrowth_Exit2_PrintsContributors() { var (v1, v2) = RequireMstats(); @@ -44,7 +48,7 @@ public async Task SizeCheck_TelemetryZeroGrowth_Exit2_PrintsContributors() "size-check", v2, "--baseline", v1, "--budget", "ns=NativeAotConsole.Telemetry:growth=0"); - Assert.Equal(2, exitCode); + Assert.AreEqual(2, exitCode); Assert.Contains("FAIL", stdout); Assert.Contains("NativeAotConsole.Telemetry", stdout); Assert.Contains("Top contributors:", stdout); @@ -55,7 +59,8 @@ public async Task SizeCheck_TelemetryZeroGrowth_Exit2_PrintsContributors() /// /// Verifies a warning-severity breach reports but exits 0 — warnings never gate. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeCheck_WarningOnlyBreach_Exit0() { var (v1, v2) = RequireMstats(); @@ -69,12 +74,12 @@ await File.WriteAllTextAsync(budgetFile, """ "growth": "0", "severity": "warning" } ] } - """, TestContext.Current.CancellationToken); + """, CancellationToken.None); var (exitCode, stdout, _) = await TestHelpers.RunDotsiderAsync( "size-check", v2, "--baseline", v1, "--budget-file", budgetFile); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("WARN", stdout); Assert.Contains("telemetry-watch", stdout); Assert.Contains("Result: PASS (with warnings)", stdout); @@ -88,23 +93,24 @@ await File.WriteAllTextAsync(budgetFile, """ /// /// Verifies a binary with no mstat sidecar exits 1 with a precise error naming the fix. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeCheck_MissingMstat_Exit1_ErrorOnStderr() { - Assert.SkipWhen(fixture.NativeAotConsoleExe is null, "AOT binary was not produced"); - Assert.SkipWhen(!File.Exists(fixture.NativeAotConsoleExe), "AOT binary missing on disk"); + TestSkip.When(Fixture.NativeAotConsoleExe is null, "AOT binary was not produced"); + TestSkip.When(!File.Exists(Fixture.NativeAotConsoleExe), "AOT binary missing on disk"); // The real exe copied alone: no sidecar in the temp directory to discover. var tempDir = Directory.CreateTempSubdirectory("dotsider-nomstat-"); try { - var lonelyExe = Path.Combine(tempDir.FullName, Path.GetFileName(fixture.NativeAotConsoleExe!)); - File.Copy(fixture.NativeAotConsoleExe!, lonelyExe); + var lonelyExe = Path.Combine(tempDir.FullName, Path.GetFileName(Fixture.NativeAotConsoleExe!)); + File.Copy(Fixture.NativeAotConsoleExe!, lonelyExe); var (exitCode, _, stderr) = await TestHelpers.RunDotsiderAsync( "size-check", lonelyExe, "--budget", "max=1gb"); - Assert.Equal(1, exitCode); + Assert.AreEqual(1, exitCode); Assert.Contains("not mstat-backed", stderr); Assert.Contains("IlcGenerateMstatFile", stderr); } @@ -119,21 +125,22 @@ public async Task SizeCheck_MissingMstat_Exit1_ErrorOnStderr() /// deterministically: either the reader recovers a partial prefix and the report /// succeeds, or the input is rejected with exit 1 — never a crash. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeCheck_TruncatedBaseline_NoCrash() { var (v1, v2) = RequireMstats(); var truncated = Path.Combine(Path.GetTempPath(), $"dotsider-badbase-{Guid.NewGuid():N}.mstat"); try { - var bytes = await File.ReadAllBytesAsync(v1, TestContext.Current.CancellationToken); + var bytes = await File.ReadAllBytesAsync(v1, CancellationToken.None); await File.WriteAllBytesAsync( - truncated, bytes[..(bytes.Length / 3)], TestContext.Current.CancellationToken); + truncated, bytes[..(bytes.Length / 3)], CancellationToken.None); var (exitCode, _, stderr) = await TestHelpers.RunDotsiderAsync( "size-check", v2, "--baseline", truncated); - Assert.True(exitCode is 0 or 1, $"unexpected exit {exitCode}: {stderr}"); + Assert.IsTrue(exitCode is 0 or 1, $"unexpected exit {exitCode}: {stderr}"); if (exitCode == 1) Assert.Contains("not mstat-backed", stderr); } @@ -144,7 +151,8 @@ await File.WriteAllBytesAsync( } /// Verifies an invalid budget spec exits 1 naming the offending part. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeCheck_InvalidBudgetSpec_Exit1() { var (v1, v2) = RequireMstats(); @@ -152,12 +160,13 @@ public async Task SizeCheck_InvalidBudgetSpec_Exit1() var (exitCode, _, stderr) = await TestHelpers.RunDotsiderAsync( "size-check", v2, "--baseline", v1, "--budget", "cap=25mb"); - Assert.Equal(1, exitCode); + Assert.AreEqual(1, exitCode); Assert.Contains("cap=25mb", stderr); } /// Verifies a growth budget without a baseline exits 1. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeCheck_GrowthBudgetWithoutBaseline_Exit1() { var (_, v2) = RequireMstats(); @@ -165,37 +174,39 @@ public async Task SizeCheck_GrowthBudgetWithoutBaseline_Exit1() var (exitCode, _, stderr) = await TestHelpers.RunDotsiderAsync( "size-check", v2, "--budget", "growth=1%"); - Assert.Equal(1, exitCode); + Assert.AreEqual(1, exitCode); Assert.Contains("needs --baseline", stderr); } /// Verifies bare report mode without a baseline exits 1 pointing at analyze --size. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeCheck_NoBaselineNoBudgets_Exit1() { var (_, v2) = RequireMstats(); var (exitCode, _, stderr) = await TestHelpers.RunDotsiderAsync("size-check", v2); - Assert.Equal(1, exitCode); + Assert.AreEqual(1, exitCode); Assert.Contains("--baseline", stderr); Assert.Contains("analyze", stderr); } /// Verifies invalid budget-file JSON exits 1. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeCheck_InvalidBudgetFileJson_Exit1() { var (v1, v2) = RequireMstats(); var budgetFile = Path.Combine(Path.GetTempPath(), $"dotsider-badjson-{Guid.NewGuid():N}.json"); try { - await File.WriteAllTextAsync(budgetFile, "{ not json", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(budgetFile, "{ not json", CancellationToken.None); var (exitCode, _, stderr) = await TestHelpers.RunDotsiderAsync( "size-check", v2, "--baseline", v1, "--budget-file", budgetFile); - Assert.Equal(1, exitCode); + Assert.AreEqual(1, exitCode); Assert.Contains("not valid JSON", stderr); } finally @@ -209,7 +220,8 @@ public async Task SizeCheck_InvalidBudgetFileJson_Exit1() /// aggregates, contributors trimmed to --top, and the budgets block with a failed /// evaluation. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeCheck_Json_DocumentShape() { var (v1, v2) = RequireMstats(); @@ -218,56 +230,58 @@ public async Task SizeCheck_Json_DocumentShape() "size-check", v2, "--baseline", v1, "--top", "5", "--budget", "ns=NativeAotConsole.Telemetry:growth=0", "--json"); - Assert.Equal(2, exitCode); + Assert.AreEqual(2, exitCode); var json = JsonSerializer.Deserialize(stdout); - Assert.Equal("mstatTotal", json.GetProperty("totalBasis").GetString()); - Assert.True(json.GetProperty("leftTotal").GetInt64() > 0); - Assert.True(json.GetProperty("rightTotal").GetInt64() > 0); - Assert.Equal("2.2", json.GetProperty("leftFormatVersion").GetString()); - Assert.NotEqual(0, json.GetProperty("summary").GetProperty("delta").GetInt64()); - Assert.True(json.GetProperty("assemblyDeltas").GetArrayLength() > 0); - Assert.True(json.GetProperty("namespaceDeltas").GetArrayLength() > 0); - Assert.True(json.GetProperty("contributors").GetArrayLength() <= 5); + Assert.AreEqual("mstatTotal", json.GetProperty("totalBasis").GetString()); + Assert.IsGreaterThan(0, json.GetProperty("leftTotal").GetInt64()); + Assert.IsGreaterThan(0, json.GetProperty("rightTotal").GetInt64()); + Assert.AreEqual("2.2", json.GetProperty("leftFormatVersion").GetString()); + Assert.AreNotEqual(0, json.GetProperty("summary").GetProperty("delta").GetInt64()); + Assert.IsGreaterThan(0, json.GetProperty("assemblyDeltas").GetArrayLength()); + Assert.IsGreaterThan(0, json.GetProperty("namespaceDeltas").GetArrayLength()); + Assert.IsLessThanOrEqualTo(5, json.GetProperty("contributors").GetArrayLength()); var budgets = json.GetProperty("budgets"); - Assert.False(budgets.GetProperty("passed").GetBoolean()); + Assert.IsFalse(budgets.GetProperty("passed").GetBoolean()); var evaluation = budgets.GetProperty("evaluations")[0]; - Assert.False(evaluation.GetProperty("passed").GetBoolean()); - Assert.True(evaluation.GetProperty("violations").GetArrayLength() > 0); - Assert.True(evaluation.GetProperty("topContributors").GetArrayLength() > 0); + Assert.IsFalse(evaluation.GetProperty("passed").GetBoolean()); + Assert.IsGreaterThan(0, evaluation.GetProperty("violations").GetArrayLength()); + Assert.IsGreaterThan(0, evaluation.GetProperty("topContributors").GetArrayLength()); } /// /// Verifies the basis rules: an mstat pair reports mstatTotal, a binary pair reports /// fileSize with both bases surfaced, and a mixed pair falls back to mstatTotal. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeCheck_BasisFollowsInputKinds() { var (v1, v2) = RequireMstats(); - Assert.SkipWhen(fixture.NativeAotConsoleExe is null, "V1 AOT binary was not produced"); - Assert.SkipWhen(fixture.NativeAotConsoleV2Exe is null, "V2 AOT binary was not produced"); + TestSkip.When(Fixture.NativeAotConsoleExe is null, "V1 AOT binary was not produced"); + TestSkip.When(Fixture.NativeAotConsoleV2Exe is null, "V2 AOT binary was not produced"); var (_, mstatPair, _) = await TestHelpers.RunDotsiderAsync( "size-check", v2, "--baseline", v1, "--json"); var mstatJson = JsonSerializer.Deserialize(mstatPair); - Assert.Equal("mstatTotal", mstatJson.GetProperty("totalBasis").GetString()); + Assert.AreEqual("mstatTotal", mstatJson.GetProperty("totalBasis").GetString()); var (_, binaryPair, _) = await TestHelpers.RunDotsiderAsync( - "size-check", fixture.NativeAotConsoleV2Exe!, - "--baseline", fixture.NativeAotConsoleExe!, "--json"); + "size-check", Fixture.NativeAotConsoleV2Exe!, + "--baseline", Fixture.NativeAotConsoleExe!, "--json"); var binaryJson = JsonSerializer.Deserialize(binaryPair); - Assert.Equal("fileSize", binaryJson.GetProperty("totalBasis").GetString()); - Assert.True(binaryJson.GetProperty("leftMstatTotal").GetInt64() > 0); - Assert.True(binaryJson.GetProperty("rightMstatTotal").GetInt64() > 0); + Assert.AreEqual("fileSize", binaryJson.GetProperty("totalBasis").GetString()); + Assert.IsGreaterThan(0, binaryJson.GetProperty("leftMstatTotal").GetInt64()); + Assert.IsGreaterThan(0, binaryJson.GetProperty("rightMstatTotal").GetInt64()); var (_, mixedPair, _) = await TestHelpers.RunDotsiderAsync( - "size-check", fixture.NativeAotConsoleV2Exe!, "--baseline", v1, "--json"); + "size-check", Fixture.NativeAotConsoleV2Exe!, "--baseline", v1, "--json"); var mixedJson = JsonSerializer.Deserialize(mixedPair); - Assert.Equal("mstatTotal", mixedJson.GetProperty("totalBasis").GetString()); + Assert.AreEqual("mstatTotal", mixedJson.GetProperty("totalBasis").GetString()); } /// Verifies markdown output renders tables and the verdict line. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeCheck_FormatMarkdown_EmitsTables() { var (v1, v2) = RequireMstats(); @@ -276,7 +290,7 @@ public async Task SizeCheck_FormatMarkdown_EmitsTables() "size-check", v2, "--baseline", v1, "--format", "markdown", "--budget", "ns=NativeAotConsole.Telemetry:growth=0"); - Assert.Equal(2, exitCode); + Assert.AreEqual(2, exitCode); Assert.Contains("## Size check", stdout); Assert.Contains("| Kind | Added | Removed | Grown | Shrunk | Unchanged |", stdout); Assert.Contains("### Budgets", stdout); @@ -287,7 +301,8 @@ public async Task SizeCheck_FormatMarkdown_EmitsTables() /// Verifies --summary-file writes the markdown report alongside the text stdout — the /// GitHub step-summary wiring. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeCheck_SummaryFile_WritesMarkdownAlongsideText() { var (v1, v2) = RequireMstats(); @@ -297,9 +312,9 @@ public async Task SizeCheck_SummaryFile_WritesMarkdownAlongsideText() var (exitCode, stdout, _) = await TestHelpers.RunDotsiderAsync( "size-check", v2, "--baseline", v1, "--summary-file", summaryFile); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("Size check:", stdout); - var markdown = await File.ReadAllTextAsync(summaryFile, TestContext.Current.CancellationToken); + var markdown = await File.ReadAllTextAsync(summaryFile, CancellationToken.None); Assert.Contains("## Size check", markdown); } finally @@ -309,7 +324,8 @@ public async Task SizeCheck_SummaryFile_WritesMarkdownAlongsideText() } /// Verifies --json conflicts with a non-json --format. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeCheck_JsonConflictingFormat_Exit1() { var (v1, v2) = RequireMstats(); @@ -317,7 +333,7 @@ public async Task SizeCheck_JsonConflictingFormat_Exit1() var (exitCode, _, stderr) = await TestHelpers.RunDotsiderAsync( "size-check", v2, "--baseline", v1, "--json", "--format", "markdown"); - Assert.Equal(1, exitCode); + Assert.AreEqual(1, exitCode); Assert.Contains("--json conflicts", stderr); } @@ -325,16 +341,17 @@ public async Task SizeCheck_JsonConflictingFormat_Exit1() /// Verifies markdown output carries the resolved why chains — a CI step summary must /// keep the dependency-chain information the text and JSON outputs include. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeCheck_MarkdownWhy_EmitsChains() { var (v1, v2) = RequireMstats(); - Assert.SkipWhen(fixture.NativeAotConsoleV2Dgml is null, "V2 DGML sidecar was not produced"); + TestSkip.When(Fixture.NativeAotConsoleV2Dgml is null, "V2 DGML sidecar was not produced"); var (exitCode, stdout, _) = await TestHelpers.RunDotsiderAsync( "size-check", v2, "--baseline", v1, "--top", "25", "--why", "--format", "markdown"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains("### Why did these appear?", stdout); Assert.Contains("kept by (root first):", stdout); } @@ -345,16 +362,17 @@ public async Task SizeCheck_MarkdownWhy_EmitsChains() /// absolute delta, yet the regressions section — added rows — must still carry chains. /// Selecting top-N before filtering to added rows would resolve none here. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeCheck_Why_CoversAddedRowsBeyondOverallTopN() { var (v1, v2) = RequireMstats(); - Assert.SkipWhen(fixture.NativeAotConsoleDgml is null, "V1 DGML sidecar was not produced"); + TestSkip.When(Fixture.NativeAotConsoleDgml is null, "V1 DGML sidecar was not produced"); var (exitCode, stdout, _) = await TestHelpers.RunDotsiderAsync( "size-check", v1, "--baseline", v2, "--top", "3", "--why"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); Assert.Contains(" why ", stdout); } @@ -362,37 +380,39 @@ public async Task SizeCheck_Why_CoversAddedRowsBeyondOverallTopN() /// Verifies --why attaches dependency chains for the top added contributors when the /// target's DGML sits beside it. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeCheck_Why_AttachesPaths() { var (v1, v2) = RequireMstats(); - Assert.SkipWhen(fixture.NativeAotConsoleV2Dgml is null, "V2 DGML sidecar was not produced"); + TestSkip.When(Fixture.NativeAotConsoleV2Dgml is null, "V2 DGML sidecar was not produced"); var (exitCode, stdout, _) = await TestHelpers.RunDotsiderAsync( "size-check", v2, "--baseline", v1, "--top", "25", "--why", "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); var json = JsonSerializer.Deserialize(stdout); var withWhy = json.GetProperty("contributors").EnumerateArray() .Where(c => c.TryGetProperty("whyPath", out var why) && why.ValueKind == JsonValueKind.Array && why.GetArrayLength() > 0) .ToList(); - Assert.NotEmpty(withWhy); + Assert.IsNotEmpty(withWhy); } /// /// Verifies diff refuses to compare an mstat-backed input against a managed assembly — /// the two sides would measure different things. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Diff_MixedMstatAndAssembly_Exit1() { var (v1, _) = RequireMstats(); var (exitCode, _, stderr) = await TestHelpers.RunDotsiderAsync( - "diff", v1, fixture.RichLibraryDll); + "diff", v1, Fixture.RichLibraryDll); - Assert.Equal(1, exitCode); + Assert.AreEqual(1, exitCode); Assert.Contains("mstat-backed", stderr); } @@ -400,7 +420,8 @@ public async Task Diff_MixedMstatAndAssembly_Exit1() /// Verifies diff --json on an mstat pair emits the size-diff document headlessly instead /// of opening the TUI — subprocess-safe because no alternate screen is entered. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Diff_MstatPairJson_EmitsSizeDiffDocument() { var (v1, v2) = RequireMstats(); @@ -408,12 +429,12 @@ public async Task Diff_MstatPairJson_EmitsSizeDiffDocument() var (exitCode, stdout, _) = await TestHelpers.RunDotsiderAsync( "diff", v1, v2, "--json"); - Assert.Equal(0, exitCode); + Assert.AreEqual(0, exitCode); var json = JsonSerializer.Deserialize(stdout); - Assert.Equal("mstatTotal", json.GetProperty("totalBasis").GetString()); - Assert.NotEqual(0, json.GetProperty("summary").GetProperty("delta").GetInt64()); - Assert.True(json.GetProperty("contributors").GetArrayLength() > 0); - Assert.False(json.TryGetProperty("budgets", out var budgets) + Assert.AreEqual("mstatTotal", json.GetProperty("totalBasis").GetString()); + Assert.AreNotEqual(0, json.GetProperty("summary").GetProperty("delta").GetInt64()); + Assert.IsGreaterThan(0, json.GetProperty("contributors").GetArrayLength()); + Assert.IsFalse(json.TryGetProperty("budgets", out var budgets) && budgets.ValueKind != JsonValueKind.Null); } } diff --git a/tests/Dotsider.Tests/SizeDiffViewTests.cs b/tests/Dotsider.Tests/SizeDiffViewTests.cs index bd7fb94a..a576d032 100644 --- a/tests/Dotsider.Tests/SizeDiffViewTests.cs +++ b/tests/Dotsider.Tests/SizeDiffViewTests.cs @@ -14,31 +14,33 @@ namespace Dotsider.Tests; /// on a headless 120×30 terminal over the real V1/V2 mstat pair, plus direct tests of the /// filter, weight, why-chain, and symbol-resolution logic against the same real data. /// -[Collection("SampleAssemblies")] -public class SizeDiffViewTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class SizeDiffViewTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _hex1bApp; private SizeDiffState? _state; - private (MstatSource V1, MstatSource V2) ResolvePair(bool binaries = false) + private static (MstatSource V1, MstatSource V2) ResolvePair(bool binaries = false) { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "V1 mstat sidecar was not produced"); - Assert.SkipWhen(samples.NativeAotConsoleV2Mstat is null, "V2 mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "V1 mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleV2Mstat is null, "V2 mstat sidecar was not produced"); - var leftPath = binaries ? samples.NativeAotConsoleExe : samples.NativeAotConsoleMstat; - var rightPath = binaries ? samples.NativeAotConsoleV2Exe : samples.NativeAotConsoleV2Mstat; + var leftPath = binaries ? Samples.NativeAotConsoleExe : Samples.NativeAotConsoleMstat; + var rightPath = binaries ? Samples.NativeAotConsoleV2Exe : Samples.NativeAotConsoleV2Mstat; if (binaries) { - Assert.SkipWhen(leftPath is null, "V1 AOT binary was not produced"); - Assert.SkipWhen(rightPath is null, "V2 AOT binary was not produced"); + TestSkip.When(leftPath is null, "V1 AOT binary was not produced"); + TestSkip.When(rightPath is null, "V2 AOT binary was not produced"); } var left = MstatLocator.Resolve(leftPath!); var right = MstatLocator.Resolve(rightPath!); - Assert.NotNull(left); - Assert.NotNull(right); + Assert.IsNotNull(left); + Assert.IsNotNull(right); return (left, right); } @@ -71,12 +73,13 @@ public class SizeDiffViewTests(SampleAssemblyFixture samples) : IDisposable /// Map tabs — never the managed diff's empty Types/Methods/References tables — with the /// treemap tab active and the signed total delta in the title bar. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeDiffApp_MstatPair_ShowsSizeTabsOnly() { var (v1, v2) = ResolvePair(); var (terminal, app) = CreateSizeDiffApp(v1, v2); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -102,12 +105,13 @@ await auto.WaitUntilAsync(s => /// Verifies the delta treemap tiles its full area with no uncovered cells — the same /// coverage guarantee the single-build Size Map holds (#134), under the |Δ| weighting. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeDiffTreemap_FillsAreaNoGaps() { var (v1, v2) = ResolvePair(); var (terminal, app) = CreateSizeDiffApp(v1, v2); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -150,8 +154,8 @@ await auto.WaitUntilAsync(s => return true; }, description: "delta treemap rendered with measurable bounds"); - Assert.True(totalTreemapCells > 0, "Could not locate treemap area"); - Assert.Equal(0, uncoveredCells); + Assert.IsGreaterThan(0, totalTreemapCells, "Could not locate treemap area"); + Assert.AreEqual(0, uncoveredCells); cts.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -161,12 +165,13 @@ await auto.WaitUntilAsync(s => /// Verifies drill-down and breadcrumb restore: Enter on a selected subtree descends (the /// breadcrumb gains a level), Esc pops back to the root level. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeDiffTreemap_DrillEnter_EscRestoresLevel() { var (v1, v2) = ResolvePair(); var (terminal, app) = CreateSizeDiffApp(v1, v2); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -195,12 +200,13 @@ await auto.WaitUntilAsync( /// Verifies f cycles all five direction filters and the drill state resets with each /// switch — the filtered tree is a different tree. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeDiffTreemap_FilterKeyCyclesFiveModes() { var (v1, v2) = ResolvePair(); var (terminal, app) = CreateSizeDiffApp(v1, v2); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -220,12 +226,13 @@ public async Task SizeDiffTreemap_FilterKeyCyclesFiveModes() /// /// Verifies the w binding opens the why popup for the targeted node. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeDiffTreemap_WhyKey_OpensPopup() { var (v1, v2) = ResolvePair(); var (terminal, app) = CreateSizeDiffApp(v1, v2); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -244,11 +251,11 @@ await auto.WaitUntilAsync(s => popupSnapshot = s; return true; }, description: "why popup rendered"); - Assert.NotNull(_state!.WhyContent); + Assert.IsNotNull(_state!.WhyContent); AssertPopupSurfaceReadable(popupSnapshot!, "Why in binary", "[right/current:"); var selectedBeforeDismiss = _state.TreemapSelectedIndex; var visibleCount = (_state.TreemapCurrentLevel ?? _state.FilteredRoot)!.Children.Count; - Assert.True(visibleCount > 1, "The fixture must expose more than one treemap item."); + Assert.IsGreaterThan(1, visibleCount, "The fixture must expose more than one treemap item."); var expectedSelection = (selectedBeforeDismiss + 1) % visibleCount; await auto.KeyAsync(Hex1bKey.Escape, ct: cts.Token); @@ -268,12 +275,13 @@ await auto.WaitUntilAsync( /// Verifies the d binding opens the disassembly popup, and that a bare-mstat pair states /// honestly that there is no binary to disassemble. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeDiffTreemap_DisasmKey_BareMstatPair_ExplainsNoBinary() { var (v1, v2) = ResolvePair(); var (terminal, app) = CreateSizeDiffApp(v1, v2); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -304,7 +312,7 @@ await auto.WaitUntilAsync(s => // --- Direct logic tests (no terminal) over the same real diff --- - private MstatDiffResult DiffV1V2() + private static MstatDiffResult DiffV1V2() { var (v1, v2) = ResolvePair(); return MstatDiffer.Compare(v1.Data, v2.Data); @@ -315,7 +323,8 @@ private MstatDiffResult DiffV1V2() /// accessor and drops the added namespace; Added does the reverse; interior sums are /// recomputed from the surviving children. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ApplyFilter_DirectionsPartitionEntries() { var diff = DiffV1V2(); @@ -329,10 +338,10 @@ static void AssertRecomputedNodeNames(SizeDiffNode node) { if (node.Children.Count == 0) return; - Assert.Equal( + Assert.AreSequenceEqual( node.Children.SelectMany(c => c.LeftNodeNames).Distinct(StringComparer.Ordinal), node.LeftNodeNames); - Assert.Equal( + Assert.AreSequenceEqual( node.Children.SelectMany(c => c.RightNodeNames).Distinct(StringComparer.Ordinal), node.RightNodeNames); foreach (var child in node.Children) @@ -348,27 +357,27 @@ static void AssertRecomputedNodeNames(SizeDiffNode node) AssertRecomputedNodeNames(diff.Root); var removed = SizeDiffTreemapView.ApplyFilter(diff.Root, SizeDiffFilterMode.Removed); - Assert.True(ContainsLeaf(removed, n => n.FullPath == GetNamePath)); - Assert.False(ContainsLeaf(removed, n => n.FullPath.Contains("Telemetry"))); - Assert.All(removed.Children, AssertRecomputedSums); - Assert.All(removed.Children, n => AssertAllDirections(n, DiffKind.Removed)); + Assert.IsTrue(ContainsLeaf(removed, n => n.FullPath == GetNamePath)); + Assert.IsFalse(ContainsLeaf(removed, n => n.FullPath.Contains("Telemetry"))); + TestAssert.All(removed.Children, AssertRecomputedSums); + TestAssert.All(removed.Children, n => AssertAllDirections(n, DiffKind.Removed)); AssertRecomputedNodeNames(removed); var added = SizeDiffTreemapView.ApplyFilter(diff.Root, SizeDiffFilterMode.Added); - Assert.False(ContainsLeaf(added, n => n.FullPath == GetNamePath)); - Assert.True(ContainsLeaf(added, n => n.FullPath.Contains("Telemetry"))); - Assert.All(added.Children, n => AssertAllDirections(n, DiffKind.Added)); + Assert.IsFalse(ContainsLeaf(added, n => n.FullPath == GetNamePath)); + Assert.IsTrue(ContainsLeaf(added, n => n.FullPath.Contains("Telemetry"))); + TestAssert.All(added.Children, n => AssertAllDirections(n, DiffKind.Added)); AssertRecomputedNodeNames(added); var grown = SizeDiffTreemapView.ApplyFilter(diff.Root, SizeDiffFilterMode.Grown); - Assert.True(ContainsLeaf(grown, n => n.FullPath == GreetStringPath)); - Assert.False(ContainsLeaf(grown, n => n.FullPath == GetNamePath)); + Assert.IsTrue(ContainsLeaf(grown, n => n.FullPath == GreetStringPath)); + Assert.IsFalse(ContainsLeaf(grown, n => n.FullPath == GetNamePath)); AssertRecomputedNodeNames(grown); static void AssertRecomputedSums(SizeDiffNode node) { if (node.Children.Count == 0) return; - Assert.Equal(node.Children.Sum(c => c.Delta), node.Delta); + Assert.AreEqual(node.Children.Sum(c => c.Delta), node.Delta); foreach (var child in node.Children) AssertRecomputedSums(child); } @@ -378,7 +387,7 @@ static void AssertRecomputedSums(SizeDiffNode node) // to be grown/shrunk when everything visible beneath it is added or removed. static void AssertAllDirections(SizeDiffNode node, DiffKind expected) { - Assert.Equal(expected, node.Diff); + Assert.AreEqual(expected, node.Diff); foreach (var child in node.Children) AssertAllDirections(child, expected); } @@ -389,7 +398,8 @@ static void AssertAllDirections(SizeDiffNode node, DiffKind expected) /// build's body first, then the baseline's — while one-sided entries offer only their /// own side. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void DisasmCandidates_ChangedEntry_CoversBothSides() { var diff = DiffV1V2(); @@ -406,17 +416,17 @@ public void DisasmCandidates_ChangedEntry_CoversBothSides() } var grown = FindLeaf(diff.Root, "NativeAotConsole/Greeter::Greet(string)"); - Assert.NotNull(grown); + Assert.IsNotNull(grown); var grownCandidates = SizeDiffTreemapView.DisasmCandidates(grown); - Assert.Contains(grownCandidates, c => !c.UseLeft); - Assert.Contains(grownCandidates, c => c.UseLeft); - Assert.False(grownCandidates[0].UseLeft); // new build first + Assert.Contains(c => !c.UseLeft, grownCandidates); + Assert.Contains(c => c.UseLeft, grownCandidates); + Assert.IsFalse(grownCandidates[0].UseLeft); // new build first var removed = FindLeaf(diff.Root, "NativeAotConsole/Greeter::get_Name()"); - Assert.NotNull(removed); + Assert.IsNotNull(removed); var removedCandidates = SizeDiffTreemapView.DisasmCandidates(removed); - Assert.NotEmpty(removedCandidates); - Assert.All(removedCandidates, c => Assert.True(c.UseLeft)); + Assert.IsNotEmpty(removedCandidates); + TestAssert.All(removedCandidates, c => Assert.IsTrue(c.UseLeft)); } /// @@ -424,7 +434,8 @@ public void DisasmCandidates_ChangedEntry_CoversBothSides() /// children cancel still weighs their churn — mass never disappears because it netted to /// zero. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Weight_InteriorChurn_NeverCancels() { var diff = DiffV1V2(); @@ -434,12 +445,12 @@ static void AssertWeights(SizeDiffNode node) var weight = SizeDiffTreemapView.Weight(node); if (node.Children.Count == 0) { - Assert.Equal(Math.Abs(node.Delta), weight); + Assert.AreEqual(Math.Abs(node.Delta), weight); } else { - Assert.Equal(node.Children.Sum(SizeDiffTreemapView.Weight), weight); - Assert.True(weight >= Math.Abs(node.Delta)); + Assert.AreEqual(node.Children.Sum(SizeDiffTreemapView.Weight), weight); + Assert.IsGreaterThanOrEqualTo(Math.Abs(node.Delta), weight); foreach (var child in node.Children) AssertWeights(child); } @@ -452,7 +463,8 @@ static void AssertWeights(SizeDiffNode node) /// Verifies the treemap label palette clears WCAG AA: for each direction background, the /// chosen black-or-white foreground contrasts at 4.5:1 or better. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void LabelForeground_PaletteClearsWcagAa() { var backgrounds = new[] @@ -467,9 +479,7 @@ public void LabelForeground_PaletteClearsWcagAa() foreach (var background in backgrounds) { var foreground = SizeDiffTreemapView.LabelForeground(background); - Assert.True( - ContrastRatio(foreground, background) >= 4.5, - $"contrast below 4.5:1 on rgb({background.R},{background.G},{background.B})"); + Assert.IsGreaterThanOrEqualTo(4.5, ContrastRatio(foreground, background), $"contrast below 4.5:1 on rgb({background.R},{background.G},{background.B})"); } static double ContrastRatio(Hex1bColor a, Hex1bColor b) @@ -495,18 +505,19 @@ static double Channel(byte v) /// /// Verifies the popup palette clears WCAG AA on its owned dark surface. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void PopupPalette_ClearsWcagAa() { - Assert.True(ContrastRatio( + Assert.IsGreaterThanOrEqualTo(4.5, ContrastRatio( SizeDiffTreemapView.PopupForeground, - SizeDiffTreemapView.PopupPanelBackground) >= 4.5); - Assert.True(ContrastRatio( + SizeDiffTreemapView.PopupPanelBackground)); + Assert.IsGreaterThanOrEqualTo(4.5, ContrastRatio( SizeDiffTreemapView.PopupLabelForeground, - SizeDiffTreemapView.PopupPanelBackground) >= 4.5); - Assert.True(ContrastRatio( + SizeDiffTreemapView.PopupPanelBackground)); + Assert.IsGreaterThanOrEqualTo(4.5, ContrastRatio( SizeDiffTreemapView.PopupBorderColor, - SizeDiffTreemapView.PopupPanelBackground) >= 4.5); + SizeDiffTreemapView.PopupPanelBackground)); } /// @@ -514,16 +525,17 @@ public void PopupPalette_ClearsWcagAa() /// to chains in the V2 graph, and the removed accessor's node names resolve in the V1 /// graph — each side answers only for its own build. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void WhyChains_ResolvePerSide() { - Assert.SkipWhen(samples.NativeAotConsoleDgml is null, "V1 DGML sidecar was not produced"); - Assert.SkipWhen(samples.NativeAotConsoleV2Dgml is null, "V2 DGML sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleDgml is null, "V1 DGML sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleV2Dgml is null, "V2 DGML sidecar was not produced"); var diff = DiffV1V2(); - var leftDgml = DgmlReader.Read(samples.NativeAotConsoleDgml!); - var rightDgml = DgmlReader.Read(samples.NativeAotConsoleV2Dgml!); - Assert.NotNull(leftDgml); - Assert.NotNull(rightDgml); + var leftDgml = DgmlReader.Read(Samples.NativeAotConsoleDgml!); + var rightDgml = DgmlReader.Read(Samples.NativeAotConsoleV2Dgml!); + Assert.IsNotNull(leftDgml); + Assert.IsNotNull(rightDgml); var added = diff.Contributors.First(c => c.Diff == DiffKind.Added && c.Namespace == "NativeAotConsole.Telemetry" @@ -533,8 +545,8 @@ public void WhyChains_ResolvePerSide() var removed = diff.Contributors.First(c => c.Name == "get_Name()" && c.AssemblyName == "NativeAotConsole"); - Assert.NotEmpty(removed.LeftNodeNames); - Assert.Empty(removed.RightNodeNames); + Assert.IsNotEmpty(removed.LeftNodeNames); + Assert.IsEmpty(removed.RightNodeNames); var removedChain = WhyChainFormatter.FormatWhyChains(leftDgml, removed.FullPath, removed.LeftNodeNames); Assert.Contains("Kept by", removedChain); } @@ -543,13 +555,14 @@ public void WhyChains_ResolvePerSide() /// Verifies an aggregate treemap tile can explain its growth directly by rolling up the /// child dependency-graph node names, so users do not need to drill to a leaf first. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void WhyChains_AggregateNodeRollsUpDescendantNames() { - Assert.SkipWhen(samples.NativeAotConsoleV2Dgml is null, "V2 DGML sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleV2Dgml is null, "V2 DGML sidecar was not produced"); var diff = DiffV1V2(); - var rightDgml = DgmlReader.Read(samples.NativeAotConsoleV2Dgml!); - Assert.NotNull(rightDgml); + var rightDgml = DgmlReader.Read(Samples.NativeAotConsoleV2Dgml!); + Assert.IsNotNull(rightDgml); static SizeDiffNode? FindNode(SizeDiffNode node, string fullPath) { @@ -563,9 +576,9 @@ public void WhyChains_AggregateNodeRollsUpDescendantNames() } var aggregate = FindNode(diff.Root, "System.Private.TypeLoader/Internal.Runtime.TypeLoader"); - Assert.NotNull(aggregate); - Assert.NotEmpty(aggregate.RightNodeNames); - Assert.True(aggregate.Children.Count > 0); + Assert.IsNotNull(aggregate); + Assert.IsNotEmpty(aggregate.RightNodeNames); + Assert.IsGreaterThan(0, aggregate.Children.Count); var chain = WhyChainFormatter.FormatWhyChains( rightDgml, aggregate.FullPath, aggregate.RightNodeNames); @@ -579,14 +592,15 @@ public void WhyChains_AggregateNodeRollsUpDescendantNames() /// overloads for free: the grown Greet(string) and the untouched Greet(int) carry /// different node names, each resolving to its own native symbol in the V2 binary. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ResolveSymbol_OverloadsResolveToDistinctSymbols() { var (_, v2Source) = ResolvePair(binaries: true); - Assert.SkipWhen(v2Source.BinaryPath is null, "V2 AOT binary was not produced"); + TestSkip.When(v2Source.BinaryPath is null, "V2 AOT binary was not produced"); using var analyzer = new AssemblyAnalyzer(v2Source.BinaryPath!); - Assert.SkipWhen( + TestSkip.When( analyzer.NativeSymbols is not { Symbols.Count: > 0 }, "native symbols were not produced for the V2 binary"); @@ -597,16 +611,16 @@ public void ResolveSymbol_OverloadsResolveToDistinctSymbols() var greetInt = index.Entries.Single(e => e.Section == MstatSectionKind.Method && e.AssemblyName == "NativeAotConsole" && e.LeafName == "Greet(int)"); - Assert.NotEmpty(greetString.NodeNames); - Assert.NotEmpty(greetInt.NodeNames); - Assert.NotEqual(greetString.NodeNames[0], greetInt.NodeNames[0]); + Assert.IsNotEmpty(greetString.NodeNames); + Assert.IsNotEmpty(greetInt.NodeNames); + Assert.AreNotEqual(greetString.NodeNames[0], greetInt.NodeNames[0]); var stringSymbol = SizeDiffTreemapView.ResolveSymbol(analyzer, greetString.NodeNames[0]); var intSymbol = SizeDiffTreemapView.ResolveSymbol(analyzer, greetInt.NodeNames[0]); - Assert.SkipWhen( + TestSkip.When( stringSymbol is null || intSymbol is null, "mstat node names not present in the symbol table on this toolchain"); - Assert.NotEqual(stringSymbol!.VirtualAddress, intSymbol!.VirtualAddress); + Assert.AreNotEqual(stringSymbol!.VirtualAddress, intSymbol!.VirtualAddress); Assert.EndsWith(greetString.NodeNames[0], stringSymbol.Name); } @@ -624,7 +638,7 @@ private static void AssertPopupSurfaceReadable( string? contentNeedle = null) { var titleY = FindLine(snapshot, title); - Assert.True(titleY >= 0, $"Could not locate popup title '{title}'."); + Assert.IsGreaterThanOrEqualTo(0, titleY, $"Could not locate popup title '{title}'."); var panelCells = 0; var sampleY = Math.Min(snapshot.Height - 1, titleY + 1); @@ -637,28 +651,28 @@ private static void AssertPopupSurfaceReadable( } } - Assert.True(panelCells >= 90, $"Popup row only had {panelCells} cells with the panel background."); + Assert.IsGreaterThanOrEqualTo(90, panelCells, $"Popup row only had {panelCells} cells with the panel background."); int contentX; int contentY; if (contentNeedle is not null) { contentY = FindLine(snapshot, contentNeedle); - Assert.True(contentY >= 0, $"Could not locate popup content '{contentNeedle}'."); + Assert.IsGreaterThanOrEqualTo(0, contentY, $"Could not locate popup content '{contentNeedle}'."); contentX = snapshot.GetLine(contentY).IndexOf(contentNeedle, StringComparison.Ordinal); - Assert.True(contentX >= 0); + Assert.IsGreaterThanOrEqualTo(0, contentX); } else { (contentX, contentY) = FindPopupTextCell(snapshot, titleY); - Assert.True(contentX >= 0 && contentY >= 0, "Could not locate popup text."); + Assert.IsTrue(contentX >= 0 && contentY >= 0, "Could not locate popup text."); } var cell = snapshot.GetCell(contentX, contentY); - Assert.NotNull(cell.Foreground); - Assert.NotNull(cell.Background); + Assert.IsNotNull(cell.Foreground); + Assert.IsNotNull(cell.Background); AssertColorEquals(SizeDiffTreemapView.PopupPanelBackground, cell.Background.Value); - Assert.True(ContrastRatio(cell.Foreground.Value, cell.Background.Value) >= 4.5); + Assert.IsGreaterThanOrEqualTo(4.5, ContrastRatio(cell.Foreground.Value, cell.Background.Value)); } private static int FindLine(Hex1b.Automation.Hex1bTerminalSnapshot snapshot, string text) @@ -705,9 +719,9 @@ actual is { } color private static void AssertColorEquals(Hex1bColor expected, Hex1bColor actual) { - Assert.Equal(expected.R, actual.R); - Assert.Equal(expected.G, actual.G); - Assert.Equal(expected.B, actual.B); + Assert.AreEqual(expected.R, actual.R); + Assert.AreEqual(expected.G, actual.G); + Assert.AreEqual(expected.B, actual.B); } private static double ContrastRatio(Hex1bColor a, Hex1bColor b) @@ -734,10 +748,10 @@ static double Channel(byte v) /// public void Dispose() { + GC.SuppressFinalize(this); _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/SizeMapViewTests.cs b/tests/Dotsider.Tests/SizeMapViewTests.cs index fc491506..c52803d5 100644 --- a/tests/Dotsider.Tests/SizeMapViewTests.cs +++ b/tests/Dotsider.Tests/SizeMapViewTests.cs @@ -12,9 +12,11 @@ namespace Dotsider.Tests; /// every recursive call instead of the remaining items' total, leaving a visible gap on /// the right side of the Size Map tab. /// -[Collection("SampleAssemblies")] -public class SizeMapViewTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class SizeMapViewTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _hex1bApp; @@ -32,7 +34,7 @@ public class SizeMapViewTests(SampleAssemblyFixture samples) : IDisposable _hex1bApp = new Hex1bApp( ctx => { - _state ??= new DotsiderState(_hex1bApp!, assemblyPath ?? samples.RichLibraryDll) + _state ??= new DotsiderState(_hex1bApp!, assemblyPath ?? Samples.RichLibraryDll) { CurrentTab = TabId.SizeMap }; @@ -54,11 +56,12 @@ public class SizeMapViewTests(SampleAssemblyFixture samples) : IDisposable /// making them detectable by scanning the screen buffer. /// See: https://github.com/willibrandon/dotsider/issues/134 /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeMap_TreemapFillsEntireArea_NoGapOnRight() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -110,8 +113,8 @@ await auto.WaitUntilAsync(s => // With the bug, roughly 26% of the treemap area (~780 cells) is uncovered. // A correctly tiling treemap has zero uncovered cells. - Assert.True(totalTreemapCells > 0, "Could not locate treemap area"); - Assert.Equal(0, uncoveredCells); + Assert.IsGreaterThan(0, totalTreemapCells, "Could not locate treemap area"); + Assert.AreEqual(0, uncoveredCells); cts.Cancel(); await runTask; @@ -125,7 +128,8 @@ await auto.WaitUntilAsync(s => /// cell uncovered when the boundary falls on a non-integer. /// See: https://github.com/willibrandon/dotsider/issues/134 /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SizeMap_CellBounds_TilesFullViewport() { const int width = 120; @@ -155,7 +159,7 @@ public void SizeMap_CellBounds_TilesFullViewport() } } - Assert.True(covered, $"Cell ({x}, {y}) is not covered by any CellBounds rect"); + Assert.IsTrue(covered, $"Cell ({x}, {y}) is not covered by any CellBounds rect"); } } } @@ -168,7 +172,8 @@ public void SizeMap_CellBounds_TilesFullViewport() /// boundary resolves to the second (later) rect, not the first. /// See: https://github.com/willibrandon/dotsider/issues/134 /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SizeMap_CellBounds_OverlappingBoundary_ResolvesToLastRect() { // Split at 68.7 → rect A covers [0, 69), rect B covers [68, 120). @@ -184,8 +189,8 @@ public void SizeMap_CellBounds_OverlappingBoundary_ResolvesToLastRect() // Cell (68, 15) is inside both CellBounds var (ax1, _, ax2, _) = SizeTreemapView.CellBounds(rects[0]); var (bx1, _, bx2, _) = SizeTreemapView.CellBounds(rects[1]); - Assert.True(68 >= ax1 && 68 < ax2, "Cell 68 should be inside rect A's bounds"); - Assert.True(68 >= bx1 && 68 < bx2, "Cell 68 should be inside rect B's bounds"); + Assert.IsTrue(68 >= ax1 && 68 < ax2, "Cell 68 should be inside rect A's bounds"); + Assert.IsTrue(68 >= bx1 && 68 < bx2, "Cell 68 should be inside rect B's bounds"); // Simulate the click handler: iterate all, keep last match (same as draw order) SizeNode? resolved = null; @@ -196,7 +201,7 @@ public void SizeMap_CellBounds_OverlappingBoundary_ResolvesToLastRect() resolved = rect.Node; } - Assert.Same(nodeB, resolved); + Assert.AreSame(nodeB, resolved); } /// @@ -205,11 +210,12 @@ public void SizeMap_CellBounds_OverlappingBoundary_ResolvesToLastRect() /// must always resolve to a node on hover. /// See: https://github.com/willibrandon/dotsider/issues/134 /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeMap_RightmostPaintedCell_IsHoverable() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -259,7 +265,7 @@ await auto.WaitUntilAsync(s => return rightmostX >= 0; }, description: "rightmost painted cell found"); - Assert.True(rightmostX >= 0, "No painted cells found on middle row"); + Assert.IsGreaterThanOrEqualTo(0, rightmostX, "No painted cells found on middle row"); // Click the rightmost painted cell — the click handler uses the same // CellBounds as drawing, so if the cell is painted it must be clickable. @@ -270,7 +276,7 @@ await auto.WaitUntilAsync(s => await auto.WaitUntilAsync(_ => _state!.TreemapHoveredItem is not null, description: "hovered item resolved for rightmost painted cell"); - Assert.NotNull(_state!.TreemapHoveredItem); + Assert.IsNotNull(_state!.TreemapHoveredItem); cts.Cancel(); await runTask; @@ -280,13 +286,14 @@ await auto.WaitUntilAsync(_ => _state!.TreemapHoveredItem is not null, /// Verifies the Size Map on a Native AOT binary with an mstat sidecar renders the /// per-assembly and category breakdown instead of the empty state. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeMap_NativeAot_RendersAssembliesAndBuckets() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - var (terminal, app) = CreateDotsiderApp(samples.NativeAotConsoleExe); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var (terminal, app) = CreateDotsiderApp(Samples.NativeAotConsoleExe); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -308,14 +315,15 @@ await auto.WaitUntilAsync(s => !s.ContainsText("No code size data available"), /// Verifies w on a node that carries a dependency-graph name opens the why-chain popup, /// and Esc dismisses it. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeMap_NativeAot_WhyKeyOpensAndEscClosesChainPopup() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); - Assert.SkipWhen(samples.NativeAotConsoleDgml is null, "DGML sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleDgml is null, "DGML sidecar was not produced"); - var (terminal, app) = CreateDotsiderApp(samples.NativeAotConsoleExe); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var (terminal, app) = CreateDotsiderApp(Samples.NativeAotConsoleExe); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -345,7 +353,7 @@ await auto.WaitUntilAsync(s => s.ContainsText("Why in binary"), await auto.EscapeAsync(ct: cts.Token); await auto.WaitUntilAsync(s => !s.ContainsText("Why in binary"), description: "why popup to close"); - Assert.Null(_state.SizeMapWhyContent); + Assert.IsNull(_state.SizeMapWhyContent); cts.Cancel(); await runTask; @@ -354,20 +362,21 @@ await auto.WaitUntilAsync(s => !s.ContainsText("Why in binary"), /// /// Verifies w without a DGML sidecar explains what is missing instead of doing nothing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeMap_NativeAot_WhyKeyWithoutDgml_ExplainsMissingGraph() { - Assert.SkipWhen(samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); + TestSkip.When(Samples.NativeAotConsoleMstat is null, "mstat sidecar was not produced"); var dir = Directory.CreateTempSubdirectory("dotsider-whynodgml-"); - var name = Path.GetFileName(samples.NativeAotConsoleExe!); + var name = Path.GetFileName(Samples.NativeAotConsoleExe!); var exeCopy = Path.Combine(dir.FullName, name); - File.Copy(samples.NativeAotConsoleExe!, exeCopy); + File.Copy(Samples.NativeAotConsoleExe!, exeCopy); var stem = name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase) ? name[..^4] : name; - File.Copy(samples.NativeAotConsoleMstat!, Path.Combine(dir.FullName, stem + ".mstat")); + File.Copy(Samples.NativeAotConsoleMstat!, Path.Combine(dir.FullName, stem + ".mstat")); var (terminal, app) = CreateDotsiderApp(exeCopy); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); try { @@ -405,11 +414,12 @@ await auto.WaitUntilAsync(s => s.ContainsText("No DGML dependency graph"), /// Verifies w is inert on a managed assembly's tree, whose nodes carry no dependency /// names. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeMap_Managed_WhyKeyIsNoOp() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -422,7 +432,7 @@ await auto.WaitUntilAsync(_ => _state?.TreemapSelectedIndex >= 0, await auto.KeyAsync(Hex1bKey.W, ct: cts.Token); await auto.WaitAsync(100, ct: cts.Token); - Assert.Null(_state!.SizeMapWhyContent); + Assert.IsNull(_state!.SizeMapWhyContent); cts.Cancel(); await runTask; @@ -433,10 +443,10 @@ await auto.WaitUntilAsync(_ => _state?.TreemapSelectedIndex >= 0, /// public void Dispose() { + GC.SuppressFinalize(this); _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/SocketDirectoryTests.cs b/tests/Dotsider.Tests/SocketDirectoryTests.cs index 46697cea..75c81f25 100644 --- a/tests/Dotsider.Tests/SocketDirectoryTests.cs +++ b/tests/Dotsider.Tests/SocketDirectoryTests.cs @@ -1,21 +1,25 @@ using Dotsider.Diagnostics; using Hex1b; using System.Collections.Concurrent; -using System.Runtime.InteropServices; +using System.Runtime.Versioning; namespace Dotsider.Tests; /// /// Tests that the socket directory and socket files are created with correct permissions. /// -[Collection("SampleAssemblies")] -public class SocketDirectoryTests(SampleAssemblyFixture samples) : IAsyncDisposable +[TestClass] +public class SocketDirectoryTests : IAsyncDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _app; private DotsiderState? _state; private DotsiderDiagnosticsListener? _listener; + private CancellationTokenSource? _appCts; + private Task? _appTask; private async Task StartTuiWithDiagnosticsAsync(CancellationToken ct) { @@ -31,7 +35,7 @@ private async Task StartTuiWithDiagnosticsAsync(CancellationToken ct) _app = new Hex1bApp( ctx => { - _state ??= new DotsiderState(_app!, samples.HelloWorldDll, pendingMutations); + _state ??= new DotsiderState(_app!, Samples.HelloWorldDll, pendingMutations); var dotsiderApp = new DotsiderApp(_state); return Task.FromResult(dotsiderApp.Build(ctx)); }, @@ -39,12 +43,13 @@ private async Task StartTuiWithDiagnosticsAsync(CancellationToken ct) { WorkloadAdapter = _workload, EnableInputCoalescing = false - }); + }); _listener = new DotsiderDiagnosticsListener(() => _state); - _listener.StartListening(overridePid: Random.Shared.Next(100_000, 999_999)); + _listener.StartListening(overridePid: TestSocketIds.NextPid()); - _ = _app.RunAsync(ct); + _appCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + _appTask = _app.RunAsync(_appCts.Token); await Task.Delay(100, ct); await TestHelpers.WaitUntilAsync( @@ -60,28 +65,35 @@ await TestHelpers.WaitUntilAsync( public async ValueTask DisposeAsync() { GC.SuppressFinalize(this); + _appCts?.Cancel(); if (_listener is not null) await _listener.DisposeAsync(); + if (_appTask is not null) + { + try { await _appTask; } + catch (OperationCanceledException) { } + } _state?.Dispose(); _app?.Dispose(); if (_terminal is not null) await _terminal.DisposeAsync(); + _appCts?.Dispose(); } /// /// Verifies ensure socket directory sets mode0700. /// - [Fact(Timeout = 30_000)] - [Trait("Platform", "Unix")] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [TestCategory("Unix")] + [OSCondition(ConditionMode.Exclude, OperatingSystems.Windows)] + [UnsupportedOSPlatform("windows")] public async Task EnsureSocketDirectory_SetsMode0700() { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return; - - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var socketPath = await StartTuiWithDiagnosticsAsync(ct); var dir = Path.GetDirectoryName(socketPath)!; var mode = File.GetUnixFileMode(dir); - Assert.Equal( + Assert.AreEqual( UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute, mode); } @@ -89,13 +101,13 @@ public async Task EnsureSocketDirectory_SetsMode0700() /// /// Verifies existing weak directory gets tightened. /// - [Fact(Timeout = 30_000)] - [Trait("Platform", "Unix")] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [TestCategory("Unix")] + [OSCondition(ConditionMode.Exclude, OperatingSystems.Windows)] + [UnsupportedOSPlatform("windows")] public async Task ExistingWeakDirectory_GetsTightened() { - if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return; - // Weaken the directory permissions first var dir = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), @@ -107,11 +119,11 @@ public async Task ExistingWeakDirectory_GetsTightened() UnixFileMode.OtherRead | UnixFileMode.OtherExecute); // Starting the TUI calls EnsureSocketDirectory which repairs permissions - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; await StartTuiWithDiagnosticsAsync(ct); var mode = File.GetUnixFileMode(dir); - Assert.Equal( + Assert.AreEqual( UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute, mode); } @@ -119,14 +131,14 @@ public async Task ExistingWeakDirectory_GetsTightened() /// /// Verifies windows directory has correct acl. /// - [Fact(Timeout = 30_000)] - [Trait("Platform", "Windows")] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [TestCategory("Windows")] + [OSCondition(ConditionMode.Include, OperatingSystems.Windows)] + [SupportedOSPlatform("windows")] public async Task WindowsDirectory_HasCorrectAcl() { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return; - - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var socketPath = await StartTuiWithDiagnosticsAsync(ct); VerifyWindowsDirectoryAcl(Path.GetDirectoryName(socketPath)!); @@ -135,20 +147,20 @@ public async Task WindowsDirectory_HasCorrectAcl() /// /// Verifies windows socket file inherits acl. /// - [Fact(Timeout = 30_000)] - [Trait("Platform", "Windows")] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [TestCategory("Windows")] + [OSCondition(ConditionMode.Include, OperatingSystems.Windows)] + [SupportedOSPlatform("windows")] public async Task WindowsSocketFile_InheritsAcl() { - if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) - return; - - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var socketPath = await StartTuiWithDiagnosticsAsync(ct); VerifyWindowsSocketFileAcl(socketPath); } - [System.Runtime.Versioning.SupportedOSPlatform("windows")] + [SupportedOSPlatform("windows")] private static void VerifyWindowsDirectoryAcl(string dir) { var dirInfo = new DirectoryInfo(dir); @@ -156,26 +168,24 @@ private static void VerifyWindowsDirectoryAcl(string dir) var rules = security.GetAccessRules(includeExplicit: true, includeInherited: false, typeof(System.Security.Principal.NTAccount)); - Assert.True(rules.Count >= 1); + Assert.IsGreaterThanOrEqualTo(1, rules.Count); var currentUser = System.Security.Principal.WindowsIdentity.GetCurrent().Name; - Assert.Contains(rules.Cast(), - r => r.IdentityReference.Value == currentUser); + Assert.Contains(r => r.IdentityReference.Value == currentUser, rules.Cast()); - Assert.True(security.AreAccessRulesProtected); + Assert.IsTrue(security.AreAccessRulesProtected); } - [System.Runtime.Versioning.SupportedOSPlatform("windows")] + [SupportedOSPlatform("windows")] private static void VerifyWindowsSocketFileAcl(string socketPath) { var fileInfo = new FileInfo(socketPath); var security = fileInfo.GetAccessControl(); - Assert.True(security.AreAccessRulesProtected); + Assert.IsTrue(security.AreAccessRulesProtected); var rules = security.GetAccessRules(includeExplicit: true, includeInherited: false, typeof(System.Security.Principal.NTAccount)); var currentUser = System.Security.Principal.WindowsIdentity.GetCurrent().Name; - Assert.Contains(rules.Cast(), - r => r.IdentityReference.Value == currentUser); + Assert.Contains(r => r.IdentityReference.Value == currentUser, rules.Cast()); } } diff --git a/tests/Dotsider.Tests/StandardModeViewTests.cs b/tests/Dotsider.Tests/StandardModeViewTests.cs index a9c9b19a..7283d5a7 100644 --- a/tests/Dotsider.Tests/StandardModeViewTests.cs +++ b/tests/Dotsider.Tests/StandardModeViewTests.cs @@ -10,9 +10,11 @@ namespace Dotsider.Tests; /// /// Tests for Standard Mode View. /// -[Collection("SampleAssemblies")] -public class StandardModeViewTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class StandardModeViewTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _hex1bApp; @@ -122,11 +124,12 @@ await auto.WaitUntilAsync(_ => /// /// Verifies app launches shows assembly name. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task App_Launches_ShowsAssemblyName() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -143,11 +146,12 @@ public async Task App_Launches_ShowsAssemblyName() /// /// Verifies tab2 shows metadata. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab2_ShowsMetadata() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -166,11 +170,12 @@ public async Task Tab2_ShowsMetadata() /// /// Verifies tab3 shows il inspector. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_ShowsIlInspector() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -189,11 +194,12 @@ public async Task Tab3_ShowsIlInspector() /// /// Verifies tab3 keeps the managed IL Inspector label for ordinary managed assemblies. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_Label_ManagedAssembly_IsIlInspector() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -210,13 +216,14 @@ public async Task Tab3_Label_ManagedAssembly_IsIlInspector() /// /// Verifies tab3 is labeled as disassembly for a native-only AOT view. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_Label_NativeAot_IsDisassembly() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.NativeAotConsoleExe!); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.NativeAotConsoleExe!); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -233,12 +240,13 @@ public async Task Tab3_Label_NativeAot_IsDisassembly() /// /// Verifies tab3 is labeled as disassembly for a raw SDK browser-wasm runtime module. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_Label_Wasm_IsDisassembly() { var wasmPath = GetWasmNativePath(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var (terminal, app) = CreateDotsiderApp(wasmPath); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -257,12 +265,13 @@ public async Task Tab3_Label_Wasm_IsDisassembly() /// Verifies the Wasm General tab keeps the references section visible in a /// short terminal even though the WebAssembly summary is taller than the pane. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task General_Wasm_ShortTerminal_KeepsAssemblyReferencesVisible() { var wasmPath = GetWasmNativePath(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var (terminal, app) = CreateDotsiderAppWithDimensions(wasmPath, width: 110, height: 20); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -283,12 +292,13 @@ public async Task General_Wasm_ShortTerminal_KeepsAssemblyReferencesVisible() /// Verifies the Wasm General tab does not reserve a large blank area /// between the content-sized summary and the references section. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task General_Wasm_TallTerminal_DoesNotPadBeforeAssemblyReferences() { var wasmPath = GetWasmNativePath(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var (terminal, app) = CreateDotsiderAppWithDimensions(wasmPath, width: 160, height: 50); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -312,7 +322,7 @@ public async Task General_Wasm_TallTerminal_DoesNotPadBeforeAssemblyReferences() .Build() .ApplyAsync(terminal, cts.Token); - Assert.InRange(refsLine - sourceLinkLine, 1, 4); + Assert.IsInRange(1, 4, refsLine - sourceLinkLine); cts.Cancel(); await runTask; @@ -321,12 +331,13 @@ public async Task General_Wasm_TallTerminal_DoesNotPadBeforeAssemblyReferences() /// /// Verifies the PE/Metadata tab routes raw Wasm modules to WebAssembly section rows. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab1_Wasm_ShowsWebAssemblySections() { var wasmPath = GetWasmNativePath(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var (terminal, app) = CreateDotsiderApp(wasmPath, TabId.PeMetadata); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -345,12 +356,13 @@ public async Task Tab1_Wasm_ShowsWebAssemblySections() /// /// Verifies the Disassembly tab presents raw Wasm functions through Wasm-specific groups. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_Wasm_ShowsWasmFunctionGroups() { var wasmPath = GetWasmNativePath(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var (terminal, app) = CreateDotsiderApp(wasmPath, TabId.IlInspector); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -369,13 +381,14 @@ public async Task Tab3_Wasm_ShowsWasmFunctionGroups() /// /// Verifies tab3 is labeled as IL plus native for ReadyToRun images. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_Label_ReadyToRun_IsIlAndNative() { - Assert.SkipWhen(samples.ReadyToRunConsoleDll is null, "ReadyToRun crossgen2 publish did not run on this leg."); + TestSkip.When(Samples.ReadyToRunConsoleDll is null, "ReadyToRun crossgen2 publish did not run on this leg."); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.ReadyToRunConsoleDll!); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.ReadyToRunConsoleDll!); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -392,11 +405,12 @@ public async Task Tab3_Label_ReadyToRun_IsIlAndNative() /// /// Verifies tab4 shows strings. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab4_ShowsStrings() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -415,11 +429,12 @@ public async Task Tab4_ShowsStrings() /// /// Verifies tab5 shows hex dump. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab5_ShowsHexDump() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -438,11 +453,12 @@ public async Task Tab5_ShowsHexDump() /// /// Verifies tab5 starts in normal mode read only. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab5_StartsInNormalMode_ReadOnly() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -454,9 +470,9 @@ public async Task Tab5_StartsInNormalMode_ReadOnly() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(HexEditMode.Normal, _state!.HexMode); - Assert.True(_state.HexEditorState.IsReadOnly); - Assert.False(_state.HexIsDirty); + Assert.AreEqual(HexEditMode.Normal, _state!.HexMode); + Assert.IsTrue(_state.HexEditorState.IsReadOnly); + Assert.IsFalse(_state.HexIsDirty); cts.Cancel(); await runTask; @@ -465,11 +481,12 @@ public async Task Tab5_StartsInNormalMode_ReadOnly() /// /// Verifies tab5 i key enters insert mode. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab5_IKey_EntersInsertMode() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -484,8 +501,8 @@ public async Task Tab5_IKey_EntersInsertMode() .ApplyAsync(terminal, cts.Token); await Task.Delay(100, cts.Token); - Assert.Equal(HexEditMode.Insert, _state!.HexMode); - Assert.False(_state.HexEditorState.IsReadOnly); + Assert.AreEqual(HexEditMode.Insert, _state!.HexMode); + Assert.IsFalse(_state.HexEditorState.IsReadOnly); cts.Cancel(); await runTask; @@ -494,11 +511,12 @@ public async Task Tab5_IKey_EntersInsertMode() /// /// Verifies tab5 esc from insert returns to normal. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab5_EscFromInsert_ReturnsToNormal() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -514,8 +532,8 @@ public async Task Tab5_EscFromInsert_ReturnsToNormal() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(HexEditMode.Normal, _state!.HexMode); - Assert.True(_state.HexEditorState.IsReadOnly); + Assert.AreEqual(HexEditMode.Normal, _state!.HexMode); + Assert.IsTrue(_state.HexEditorState.IsReadOnly); cts.Cancel(); await runTask; @@ -524,11 +542,12 @@ public async Task Tab5_EscFromInsert_ReturnsToNormal() /// /// Verifies tab5 esc from insert with confirmed search exits insert first. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab5_EscFromInsert_WithConfirmedSearch_ExitsInsertFirst() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -554,10 +573,10 @@ public async Task Tab5_EscFromInsert_WithConfirmedSearch_ExitsInsertFirst() .ApplyAsync(terminal, cts.Token); // Insert mode must be exited - Assert.Equal(HexEditMode.Normal, _state!.HexMode); - Assert.True(_state.HexEditorState.IsReadOnly); + Assert.AreEqual(HexEditMode.Normal, _state!.HexMode); + Assert.IsTrue(_state.HexEditorState.IsReadOnly); // Search should still be active (not dismissed by this Esc) - Assert.True(_state.Search[TabId.HexDump].IsActive); + Assert.IsTrue(_state.Search[TabId.HexDump].IsActive); cts.Cancel(); await runTask; @@ -566,11 +585,12 @@ public async Task Tab5_EscFromInsert_WithConfirmedSearch_ExitsInsertFirst() /// /// Verifies tab5 normal mode vim keys navigate. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab5_NormalMode_VimKeysNavigate() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -591,9 +611,9 @@ public async Task Tab5_NormalMode_VimKeysNavigate() .Build() .ApplyAsync(terminal, cts.Token); - Assert.NotEqual(cursorBefore, _state.HexEditorState.Cursor.Position); + Assert.AreNotEqual(cursorBefore, _state.HexEditorState.Cursor.Position); // Document should NOT be modified — we're in normal mode - Assert.False(_state.HexIsDirty); + Assert.IsFalse(_state.HexIsDirty); cts.Cancel(); await runTask; @@ -602,11 +622,12 @@ public async Task Tab5_NormalMode_VimKeysNavigate() /// /// Verifies tab5 insert mode s key does not toggle size. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab5_InsertMode_SKey_DoesNotToggleSize() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -627,7 +648,7 @@ public async Task Tab5_InsertMode_SKey_DoesNotToggleSize() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(sizesBefore, _state.HumanReadableSizes); + Assert.AreEqual(sizesBefore, _state.HumanReadableSizes); cts.Cancel(); await runTask; @@ -636,11 +657,12 @@ public async Task Tab5_InsertMode_SKey_DoesNotToggleSize() /// /// Verifies tab5 insert mode q key does not quit. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab5_InsertMode_QKey_DoesNotQuit() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -665,11 +687,12 @@ public async Task Tab5_InsertMode_QKey_DoesNotQuit() /// /// Verifies tab5 insert mode number keys do not switch tabs. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab5_InsertMode_NumberKeys_DoNotSwitchTabs() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -684,7 +707,7 @@ public async Task Tab5_InsertMode_NumberKeys_DoNotSwitchTabs() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(TabId.HexDump, _state!.CurrentTab); + Assert.AreEqual(TabId.HexDump, _state!.CurrentTab); cts.Cancel(); await runTask; @@ -693,11 +716,12 @@ public async Task Tab5_InsertMode_NumberKeys_DoNotSwitchTabs() /// /// Verifies tab5 normal mode no insert indicator. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab5_NormalMode_NoInsertIndicator() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -711,7 +735,7 @@ public async Task Tab5_NormalMode_NoInsertIndicator() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(HexEditMode.Normal, _state!.HexMode); + Assert.AreEqual(HexEditMode.Normal, _state!.HexMode); cts.Cancel(); await runTask; @@ -720,18 +744,19 @@ public async Task Tab5_NormalMode_NoInsertIndicator() /// /// Verifies tab5 ctrl s saves with correct file name. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab5_CtrlS_SavesWithCorrectFileName() { // Work on a disposable copy so we don't modify the shared fixture assembly var tempDir = Path.Combine(Path.GetTempPath(), $"dotsider-test-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDir); var tempDll = Path.Combine(tempDir, "HelloWorld.dll"); - File.Copy(samples.HelloWorldDll, tempDll); + File.Copy(Samples.HelloWorldDll, tempDll); try { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var (terminal, app) = CreateDotsiderApp(tempDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -760,12 +785,12 @@ public async Task Tab5_CtrlS_SavesWithCorrectFileName() .ApplyAsync(terminal, cts.Token); // FilePath must be the original, not the .tmp fallback - Assert.Equal(tempDll, _state!.Analyzer.FilePath); + Assert.AreEqual(tempDll, _state!.Analyzer.FilePath); Assert.DoesNotContain(".tmp", _state.Analyzer.FileName); - Assert.Contains("written", _state.HexNotification); - Assert.Contains("HelloWorld.dll", _state.HexNotification); + Assert.Contains("written", _state.HexNotification!); + Assert.Contains("HelloWorld.dll", _state.HexNotification!); // No temp file should remain - Assert.False(File.Exists(tempDll + ".tmp")); + Assert.IsFalse(File.Exists(tempDll + ".tmp")); cts.Cancel(); await runTask; @@ -779,11 +804,12 @@ public async Task Tab5_CtrlS_SavesWithCorrectFileName() /// /// Verifies tab6 shows dep graph. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_ShowsDepGraph() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -807,11 +833,12 @@ public async Task Tab6_ShowsDepGraph() /// probe must roll forward (same well-known framework PKT, equal-or-higher version) and /// the cached graph must collapse onto a single resolved node keyed at the deployed version. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_AppLocalRollForward_NoIdentityMismatchMarker() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.AppLocalRollForwardDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.AppLocalRollForwardDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -825,15 +852,15 @@ public async Task Tab6_AppLocalRollForward_NoIdentityMismatchMarker() .ApplyAsync(terminal, cts.Token); var snapshot = terminal.CreateSnapshot(); - Assert.False(snapshot.ContainsText("! Microsoft.Diagnostics.NETCore.Client"), + Assert.IsFalse(snapshot.ContainsText("! Microsoft.Diagnostics.NETCore.Client"), "AppLocal roll-forward must suppress the IdentityMismatch marker on the Dep Graph tab."); - Assert.NotNull(_state!.CachedGraph); + Assert.IsNotNull(_state!.CachedGraph); var clientNodes = _state.CachedGraph!.Value.Nodes .Where(n => n.Name == "Microsoft.Diagnostics.NETCore.Client") .ToList(); - var clientNode = Assert.Single(clientNodes); - Assert.False(clientNode.Unresolved); + var clientNode = Assert.ContainsSingle(clientNodes); + Assert.IsFalse(clientNode.Unresolved); cts.Cancel(); await runTask; @@ -842,11 +869,12 @@ public async Task Tab6_AppLocalRollForward_NoIdentityMismatchMarker() /// /// Verifies tab7 shows size map. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab7_ShowsSizeMap() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -865,11 +893,12 @@ public async Task Tab7_ShowsSizeMap() /// /// Verifies tab6 shows node and edge counts. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_ShowsNodeAndEdgeCounts() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -881,9 +910,9 @@ public async Task Tab6_ShowsNodeAndEdgeCounts() .Build() .ApplyAsync(terminal, cts.Token); - Assert.NotNull(_state!.CachedGraph); - Assert.True(_state.CachedGraph.Value.Nodes.Count > 0); - Assert.True(_state.CachedGraph.Value.Edges.Count > 0); + Assert.IsNotNull(_state!.CachedGraph); + Assert.IsGreaterThan(0, _state.CachedGraph.Value.Nodes.Count); + Assert.IsGreaterThan(0, _state.CachedGraph.Value.Edges.Count); cts.Cancel(); await runTask; @@ -892,11 +921,12 @@ public async Task Tab6_ShowsNodeAndEdgeCounts() /// /// Verifies tab6 search shows match count. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_SearchShowsMatchCount() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -916,8 +946,7 @@ public async Task Tab6_SearchShowsMatchCount() .Build() .ApplyAsync(terminal, cts.Token); - Assert.True(_state!.Search[TabId.DepGraph].MatchCount > 0, - "Search for 'sys' should match System.* dependencies"); + Assert.IsGreaterThan(0, _state!.Search[TabId.DepGraph].MatchCount, "Search for 'sys' should match System.* dependencies"); cts.Cancel(); await runTask; @@ -926,11 +955,12 @@ public async Task Tab6_SearchShowsMatchCount() /// /// Verifies tab6 match navigation cycles graph selected node. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_MatchNavigation_CyclesGraphSelectedNode() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -956,7 +986,7 @@ public async Task Tab6_MatchNavigation_CyclesGraphSelectedNode() .ApplyAsync(terminal, cts.Token); var firstIndex = _state!.GraphMatchIndex; - Assert.True(firstIndex >= 0); + Assert.IsGreaterThanOrEqualTo(0, firstIndex); // Press 'n' again — should advance to next match await new Hex1bTerminalInputSequenceBuilder() @@ -968,7 +998,7 @@ public async Task Tab6_MatchNavigation_CyclesGraphSelectedNode() .ApplyAsync(terminal, cts.Token); if (_state.Search[TabId.DepGraph].MatchCount > 1) - Assert.NotEqual(firstIndex, _state.GraphMatchIndex); + Assert.AreNotEqual(firstIndex, _state.GraphMatchIndex); cts.Cancel(); await runTask; @@ -977,11 +1007,12 @@ public async Task Tab6_MatchNavigation_CyclesGraphSelectedNode() /// /// Verifies tab6 arrow keys work after search confirm. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_ArrowKeys_WorkAfterSearchConfirm() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1000,7 +1031,7 @@ public async Task Tab6_ArrowKeys_WorkAfterSearchConfirm() .ApplyAsync(terminal, cts.Token); // Arrow keys should work immediately — focus restored to Interactable - Assert.Equal(-1, _state!.GraphSelectedIndex); + Assert.AreEqual(-1, _state!.GraphSelectedIndex); await new Hex1bTerminalInputSequenceBuilder() .Key(Hex1bKey.RightArrow) @@ -1008,7 +1039,7 @@ public async Task Tab6_ArrowKeys_WorkAfterSearchConfirm() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(0, _state.GraphSelectedIndex); + Assert.AreEqual(0, _state.GraphSelectedIndex); cts.Cancel(); await runTask; @@ -1017,11 +1048,12 @@ public async Task Tab6_ArrowKeys_WorkAfterSearchConfirm() /// /// Verifies tab7 arrow keys work after search confirm. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab7_ArrowKeys_WorkAfterSearchConfirm() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1040,7 +1072,7 @@ public async Task Tab7_ArrowKeys_WorkAfterSearchConfirm() .ApplyAsync(terminal, cts.Token); // Arrow keys should work immediately — focus restored to Interactable - Assert.Equal(-1, _state!.TreemapSelectedIndex); + Assert.AreEqual(-1, _state!.TreemapSelectedIndex); await new Hex1bTerminalInputSequenceBuilder() .Key(Hex1bKey.RightArrow) @@ -1048,7 +1080,7 @@ public async Task Tab7_ArrowKeys_WorkAfterSearchConfirm() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(0, _state.TreemapSelectedIndex); + Assert.AreEqual(0, _state.TreemapSelectedIndex); cts.Cancel(); await runTask; @@ -1057,12 +1089,13 @@ public async Task Tab7_ArrowKeys_WorkAfterSearchConfirm() /// /// Verifies tab6 startup focus arrow keys work without tab switch. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_StartupFocus_ArrowKeysWorkWithoutTabSwitch() { // Start directly on Dep Graph tab — tests the initial focus predicate - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll, initialTab: TabId.DepGraph); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll, initialTab: TabId.DepGraph); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1072,7 +1105,7 @@ public async Task Tab6_StartupFocus_ArrowKeysWorkWithoutTabSwitch() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(-1, _state!.GraphSelectedIndex); + Assert.AreEqual(-1, _state!.GraphSelectedIndex); // Arrow keys should work immediately without switching tabs first await new Hex1bTerminalInputSequenceBuilder() @@ -1081,7 +1114,7 @@ public async Task Tab6_StartupFocus_ArrowKeysWorkWithoutTabSwitch() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(0, _state.GraphSelectedIndex); + Assert.AreEqual(0, _state.GraphSelectedIndex); cts.Cancel(); await runTask; @@ -1090,12 +1123,13 @@ public async Task Tab6_StartupFocus_ArrowKeysWorkWithoutTabSwitch() /// /// Verifies tab7 startup focus arrow keys work without tab switch. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab7_StartupFocus_ArrowKeysWorkWithoutTabSwitch() { // Start directly on Size Map tab — tests the initial focus predicate - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll, initialTab: TabId.SizeMap); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll, initialTab: TabId.SizeMap); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1105,7 +1139,7 @@ public async Task Tab7_StartupFocus_ArrowKeysWorkWithoutTabSwitch() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(-1, _state!.TreemapSelectedIndex); + Assert.AreEqual(-1, _state!.TreemapSelectedIndex); // Arrow keys should work immediately without switching tabs first await new Hex1bTerminalInputSequenceBuilder() @@ -1114,7 +1148,7 @@ public async Task Tab7_StartupFocus_ArrowKeysWorkWithoutTabSwitch() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(0, _state.TreemapSelectedIndex); + Assert.AreEqual(0, _state.TreemapSelectedIndex); cts.Cancel(); await runTask; @@ -1123,11 +1157,12 @@ public async Task Tab7_StartupFocus_ArrowKeysWorkWithoutTabSwitch() /// /// Verifies tab6 arrow keys cycle selected index. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_ArrowKeys_CycleSelectedIndex() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1140,7 +1175,7 @@ public async Task Tab6_ArrowKeys_CycleSelectedIndex() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(-1, _state!.GraphSelectedIndex); + Assert.AreEqual(-1, _state!.GraphSelectedIndex); // Press Right to select first node await new Hex1bTerminalInputSequenceBuilder() @@ -1149,7 +1184,7 @@ public async Task Tab6_ArrowKeys_CycleSelectedIndex() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(0, _state.GraphSelectedIndex); + Assert.AreEqual(0, _state.GraphSelectedIndex); // Press Right again — should advance await new Hex1bTerminalInputSequenceBuilder() @@ -1158,7 +1193,7 @@ public async Task Tab6_ArrowKeys_CycleSelectedIndex() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(1, _state.GraphSelectedIndex); + Assert.AreEqual(1, _state.GraphSelectedIndex); // Press Left — should go back await new Hex1bTerminalInputSequenceBuilder() @@ -1167,7 +1202,7 @@ public async Task Tab6_ArrowKeys_CycleSelectedIndex() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(0, _state.GraphSelectedIndex); + Assert.AreEqual(0, _state.GraphSelectedIndex); cts.Cancel(); await runTask; @@ -1178,11 +1213,12 @@ public async Task Tab6_ArrowKeys_CycleSelectedIndex() /// reports hidden counts, and the state flag flips. Root stays visible by contract — /// verified by asserting the nodes cached for the filtered view still include the root id. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_FilterToggle_HidesFrameworkAssembliesAndKeepsRootVisible() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1194,11 +1230,11 @@ public async Task Tab6_FilterToggle_HidesFrameworkAssembliesAndKeepsRootVisible( .Build() .ApplyAsync(terminal, cts.Token); - Assert.False(_state!.DepGraphHideFramework); - Assert.NotNull(_state.CachedGraph); + Assert.IsFalse(_state!.DepGraphHideFramework); + Assert.IsNotNull(_state.CachedGraph); var rootId = _state.CachedGraph!.Value.Nodes.First(n => n.IsRoot).Id; - Assert.NotNull(_state.GraphNavigation); - Assert.Contains(_state.CachedGraph.Value.Nodes, n => _state.GraphNavigation!.TryGetValue(n.Id, out var c) && c.IsFrameworkAssembly); + Assert.IsNotNull(_state.GraphNavigation); + Assert.Contains(n => _state.GraphNavigation!.TryGetValue(n.Id, out var c) && c.IsFrameworkAssembly, _state.CachedGraph.Value.Nodes); await new Hex1bTerminalInputSequenceBuilder() .Key(Hex1bKey.F) @@ -1207,9 +1243,9 @@ public async Task Tab6_FilterToggle_HidesFrameworkAssembliesAndKeepsRootVisible( .Build() .ApplyAsync(terminal, cts.Token); - Assert.True(_state.DepGraphHideFramework); + Assert.IsTrue(_state.DepGraphHideFramework); // Root id is still in the cached graph after toggle (underlying graph not rebuilt). - Assert.Contains(_state.CachedGraph.Value.Nodes, n => n.Id == rootId && n.IsRoot); + Assert.Contains(n => n.Id == rootId && n.IsRoot, _state.CachedGraph.Value.Nodes); cts.Cancel(); await runTask; @@ -1220,11 +1256,12 @@ public async Task Tab6_FilterToggle_HidesFrameworkAssembliesAndKeepsRootVisible( /// DirectOnly. The status line reflects the active scope and selection / match /// indices reset on each change so stale indices cannot survive into a smaller view. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_ScopeToggle_SwitchesBetweenAllAndDirectOnly() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1236,7 +1273,7 @@ public async Task Tab6_ScopeToggle_SwitchesBetweenAllAndDirectOnly() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(Views.DependencyGraphScope.All, _state!.DepGraphScope); + Assert.AreEqual(Views.DependencyGraphScope.All, _state!.DepGraphScope); await new Hex1bTerminalInputSequenceBuilder() .Key(Hex1bKey.D) @@ -1246,9 +1283,9 @@ public async Task Tab6_ScopeToggle_SwitchesBetweenAllAndDirectOnly() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(Views.DependencyGraphScope.DirectOnly, _state.DepGraphScope); - Assert.Equal(-1, _state.GraphSelectedIndex); - Assert.Equal(-1, _state.GraphMatchIndex); + Assert.AreEqual(Views.DependencyGraphScope.DirectOnly, _state.DepGraphScope); + Assert.AreEqual(-1, _state.GraphSelectedIndex); + Assert.AreEqual(-1, _state.GraphMatchIndex); await new Hex1bTerminalInputSequenceBuilder() .Key(Hex1bKey.D) @@ -1258,7 +1295,7 @@ public async Task Tab6_ScopeToggle_SwitchesBetweenAllAndDirectOnly() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(Views.DependencyGraphScope.All, _state.DepGraphScope); + Assert.AreEqual(Views.DependencyGraphScope.All, _state.DepGraphScope); cts.Cancel(); await runTask; @@ -1271,11 +1308,12 @@ public async Task Tab6_ScopeToggle_SwitchesBetweenAllAndDirectOnly() /// showed the last row. Regression test: select a node near the top, press End, /// and assert the scroll offset matches the full ContentHeight - viewport range. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_End_ScrollsToBottom_EvenWhenNodeSelected() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1289,12 +1327,12 @@ public async Task Tab6_End_ScrollsToBottom_EvenWhenNodeSelected() .Build() .ApplyAsync(terminal, cts.Token); - Assert.NotNull(_state!.CachedGraphRenderLayout); - Assert.NotNull(_state.CachedGraphRenderLayoutKey); + Assert.IsNotNull(_state!.CachedGraphRenderLayout); + Assert.IsNotNull(_state.CachedGraphRenderLayoutKey); var layout = _state.CachedGraphRenderLayout!; var key = _state.CachedGraphRenderLayoutKey!.Value; var expectedMax = Math.Max(0, layout.ContentHeight - key.Height); - Assert.True(expectedMax > 0, "viewport must be smaller than content for this test"); + Assert.IsGreaterThan(0, expectedMax, "viewport must be smaller than content for this test"); await new Hex1bTerminalInputSequenceBuilder() .Key(Hex1bKey.End) @@ -1302,7 +1340,7 @@ public async Task Tab6_End_ScrollsToBottom_EvenWhenNodeSelected() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(expectedMax, _state.DepGraphScrollY); + Assert.AreEqual(expectedMax, _state.DepGraphScrollY); cts.Cancel(); await runTask; @@ -1314,17 +1352,18 @@ public async Task Tab6_End_ScrollsToBottom_EvenWhenNodeSelected() /// returns /// for this shape and the bar renders nothing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ComputeScrollbarInputs_BeforeLayoutReady_ReturnsSafeDefaults() { - var (_, app) = CreateDotsiderApp(samples.RichLibraryDll); - using var state = new DotsiderState(app, samples.RichLibraryDll); - Assert.Null(state.CachedGraphRenderLayout); - Assert.Null(state.CachedGraphRenderLayoutKey); + var (_, app) = CreateDotsiderApp(Samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); + Assert.IsNull(state.CachedGraphRenderLayout); + Assert.IsNull(state.CachedGraphRenderLayoutKey); var inputs = DependencyGraphView.ComputeScrollbarInputs(state); - Assert.Equal((0, 1, 0), inputs); + Assert.AreEqual((0, 1, 0), inputs); } /// @@ -1332,11 +1371,12 @@ public void ComputeScrollbarInputs_BeforeLayoutReady_ReturnsSafeDefaults() /// once a layout is cached the helper clamps that to ContentHeight - Height so the /// scrollbar receives a valid offset rather than an out-of-range stub. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ComputeScrollbarInputs_ClampsScrollYToMax_AfterEnd() { - var (_, app) = CreateDotsiderApp(samples.RichLibraryDll); - using var state = new DotsiderState(app, samples.RichLibraryDll); + var (_, app) = CreateDotsiderApp(Samples.RichLibraryDll); + using var state = new DotsiderState(app, Samples.RichLibraryDll); const int contentHeight = 200; const int viewportHeight = 25; @@ -1352,20 +1392,21 @@ public void ComputeScrollbarInputs_ClampsScrollYToMax_AfterEnd() var (c, v, o) = DependencyGraphView.ComputeScrollbarInputs(state); - Assert.Equal(contentHeight, c); - Assert.Equal(viewportHeight, v); - Assert.Equal(contentHeight - viewportHeight, o); + Assert.AreEqual(contentHeight, c); + Assert.AreEqual(viewportHeight, v); + Assert.AreEqual(contentHeight - viewportHeight, o); } /// /// The scrollbar replaces the textual Scroll: N/M suffix the status line used to /// carry. Asserts the suffix is gone so reviewers don't have to grep for it. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_StatusLine_DoesNotContainScrollSuffix() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1377,7 +1418,7 @@ public async Task Tab6_StatusLine_DoesNotContainScrollSuffix() .ApplyAsync(terminal, cts.Token); var snapshot = terminal.CreateSnapshot(); - Assert.False(snapshot.ContainsText("Scroll:"), + Assert.IsFalse(snapshot.ContainsText("Scroll:"), "Status line should not carry a textual Scroll: suffix anymore."); cts.Cancel(); @@ -1388,11 +1429,12 @@ public async Task Tab6_StatusLine_DoesNotContainScrollSuffix() /// When the laid-out graph is taller than the viewport, the scrollbar's thumb glyph /// () renders at least once in the rightmost column inside the graph viewport. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_Scrollbar_RendersAtRightEdge_WhenContentExceedsViewport() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1433,7 +1475,7 @@ public async Task Tab6_Scrollbar_RendersAtRightEdge_WhenContentExceedsViewport() break; } } - Assert.True(hasThumb, "Expected at least one scrollbar thumb cell in the rightmost column."); + Assert.IsTrue(hasThumb, "Expected at least one scrollbar thumb cell in the rightmost column."); cts.Cancel(); await runTask; @@ -1444,11 +1486,12 @@ public async Task Tab6_Scrollbar_RendersAtRightEdge_WhenContentExceedsViewport() /// (FixedWidth(1)) but renders no thumb glyphs — /// paints nothing when ContentSize <= ViewportSize. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_Scrollbar_HiddenWhenContentFits() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1468,7 +1511,7 @@ public async Task Tab6_Scrollbar_HiddenWhenContentFits() for (int y = 0; y < snapshot.Height; y++) { var ch = snapshot.GetCell(rightCol, y).Character; - Assert.NotEqual("▉", ch); + Assert.AreNotEqual("▉", ch); } cts.Cancel(); @@ -1479,11 +1522,12 @@ public async Task Tab6_Scrollbar_HiddenWhenContentFits() /// Mouse wheel down over the graph surface advances /// via the new MouseButton.ScrollDown binding. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_MouseWheelDown_AdvancesScrollY() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderAppWithMouse(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderAppWithMouse(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1494,7 +1538,7 @@ public async Task Tab6_MouseWheelDown_AdvancesScrollY() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(0, _state!.DepGraphScrollY); + Assert.AreEqual(0, _state!.DepGraphScrollY); await new Hex1bTerminalInputSequenceBuilder() .MouseMoveTo(40, 10) @@ -1503,7 +1547,7 @@ public async Task Tab6_MouseWheelDown_AdvancesScrollY() .Build() .ApplyAsync(terminal, cts.Token); - Assert.True(_state.DepGraphScrollY > 0); + Assert.IsGreaterThan(0, _state.DepGraphScrollY); cts.Cancel(); await runTask; @@ -1512,11 +1556,12 @@ public async Task Tab6_MouseWheelDown_AdvancesScrollY() /// /// Wheel up at the top is clamped to zero by SetScroll's lower bound. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_MouseWheelUp_AtTop_StaysAtZero() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderAppWithMouse(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderAppWithMouse(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1531,7 +1576,7 @@ public async Task Tab6_MouseWheelUp_AtTop_StaysAtZero() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(0, _state!.DepGraphScrollY); + Assert.AreEqual(0, _state!.DepGraphScrollY); cts.Cancel(); await runTask; @@ -1540,11 +1585,12 @@ public async Task Tab6_MouseWheelUp_AtTop_StaysAtZero() /// /// Wheel down at the bottom is clamped to max by SetScroll's upper bound. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_MouseWheelDown_AtBottom_DoesNotExceedMax() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderAppWithMouse(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderAppWithMouse(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1556,11 +1602,11 @@ public async Task Tab6_MouseWheelDown_AtBottom_DoesNotExceedMax() .Build() .ApplyAsync(terminal, cts.Token); - Assert.NotNull(_state!.CachedGraphRenderLayout); - Assert.NotNull(_state.CachedGraphRenderLayoutKey); + Assert.IsNotNull(_state!.CachedGraphRenderLayout); + Assert.IsNotNull(_state.CachedGraphRenderLayoutKey); var max = Math.Max(0, _state.CachedGraphRenderLayout!.ContentHeight - _state.CachedGraphRenderLayoutKey!.Value.Height); - Assert.True(max > 0, "viewport must be smaller than content for this test"); + Assert.IsGreaterThan(0, max, "viewport must be smaller than content for this test"); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(_ => _state.DepGraphScrollY == max, TimeSpan.FromSeconds(5)) @@ -1571,7 +1617,7 @@ public async Task Tab6_MouseWheelDown_AtBottom_DoesNotExceedMax() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(max, _state.DepGraphScrollY); + Assert.AreEqual(max, _state.DepGraphScrollY); cts.Cancel(); await runTask; @@ -1582,11 +1628,12 @@ public async Task Tab6_MouseWheelDown_AtBottom_DoesNotExceedMax() /// step. Assert exact equality on the page size, /// which is determined by hex1b alone. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_TrackClick_PagesScrollY_ByViewportMinusOne() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderAppWithMouse(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderAppWithMouse(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1597,12 +1644,12 @@ public async Task Tab6_TrackClick_PagesScrollY_ByViewportMinusOne() .Build() .ApplyAsync(terminal, cts.Token); - Assert.NotNull(_state!.CachedGraphRenderLayout); - Assert.NotNull(_state.CachedGraphRenderLayoutKey); + Assert.IsNotNull(_state!.CachedGraphRenderLayout); + Assert.IsNotNull(_state.CachedGraphRenderLayoutKey); var viewport = _state.CachedGraphRenderLayoutKey!.Value.Height; var max = Math.Max(0, _state.CachedGraphRenderLayout!.ContentHeight - viewport); - Assert.True(max >= viewport - 1, "viewport must allow at least one page step"); - Assert.Equal(0, _state.DepGraphScrollY); + Assert.IsGreaterThanOrEqualTo(viewport - 1, max, "viewport must allow at least one page step"); + Assert.AreEqual(0, _state.DepGraphScrollY); // Derive the click target from the live scrollbar bounds rather than a fixed cell: the // graph layout — and therefore the track's position and length — varies with content. @@ -1623,7 +1670,7 @@ await auto.WaitUntilAsync(s => s.GetCell(sbCol, trackTop).Character == "▉", .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(viewport - 1, _state.DepGraphScrollY); + Assert.AreEqual(viewport - 1, _state.DepGraphScrollY); cts.Cancel(); await runTask; @@ -1634,11 +1681,12 @@ await auto.WaitUntilAsync(s => s.GetCell(sbCol, trackTop).Character == "▉", /// the composition fix: if the scrollbar were nested inside the Interactable, drag events /// would never reach and this test would fail. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_ThumbDrag_UpdatesScrollY() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderAppWithMouse(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderAppWithMouse(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1649,7 +1697,7 @@ public async Task Tab6_ThumbDrag_UpdatesScrollY() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(0, _state!.DepGraphScrollY); + Assert.AreEqual(0, _state!.DepGraphScrollY); // Grab the thumb at its real position (the track top at offset 0) and drag down within the // track, rather than assuming fixed rows: any downward movement maps to a positive offset. @@ -1664,7 +1712,7 @@ await auto.WaitUntilAsync(s => s.GetCell(sbCol, trackTop).Character == "▉", .Build() .ApplyAsync(terminal, cts.Token); - Assert.True(_state.DepGraphScrollY > 0); + Assert.IsGreaterThan(0, _state.DepGraphScrollY); cts.Cancel(); await runTask; @@ -1681,11 +1729,12 @@ await auto.WaitUntilAsync(s => s.GetCell(sbCol, trackTop).Character == "▉", /// render runs, and selection would not advance. Synchronous bounce inside /// VScrollbar.OnScroll via FocusWhere is what makes this test pass. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_AfterScrollbarDrag_RightArrow_AdvancesSelection() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderAppWithMouse(samples.RichLibraryDll, enableInputCoalescing: true); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderAppWithMouse(Samples.RichLibraryDll, enableInputCoalescing: true); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1696,7 +1745,7 @@ public async Task Tab6_AfterScrollbarDrag_RightArrow_AdvancesSelection() .Build() .ApplyAsync(terminal, cts.Token); - Assert.NotNull(_state!.CachedGraphRenderLayout); + Assert.IsNotNull(_state!.CachedGraphRenderLayout); var beforeIndex = _state.GraphSelectedIndex; var sbCol = 119; @@ -1707,7 +1756,7 @@ public async Task Tab6_AfterScrollbarDrag_RightArrow_AdvancesSelection() .Build() .ApplyAsync(terminal, cts.Token); - Assert.NotEqual(beforeIndex, _state.GraphSelectedIndex); + Assert.AreNotEqual(beforeIndex, _state.GraphSelectedIndex); cts.Cancel(); await runTask; @@ -1736,11 +1785,12 @@ public async Task Tab6_AfterScrollbarDrag_RightArrow_AdvancesSelection() /// production path users actually hit. /// /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_NoOpScrollbarClick_RightArrow_AdvancesSelection() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderAppWithMouse(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderAppWithMouse(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1783,23 +1833,21 @@ public async Task Tab6_NoOpScrollbarClick_RightArrow_AdvancesSelection() break; } } - Assert.True(sbRow >= 0, "could not find a scrollbar thumb cell at the rightmost column"); + Assert.IsGreaterThanOrEqualTo(0, sbRow, "could not find a scrollbar thumb cell at the rightmost column"); // Verify that the live ScrollbarNode's bounds actually contain the click target — // so the input router's hit-test will route the click to the scrollbar (and not, say, // a status-row text cell that happens to draw a │). This is the key check the review // called out: without it, the test could pass for the wrong reason. var scrollbar = _hex1bApp!.Focusables.OfType().FirstOrDefault(); - Assert.NotNull(scrollbar); + Assert.IsNotNull(scrollbar); var bounds = scrollbar!.Bounds; - Assert.True( - sbCol >= bounds.X && sbCol < bounds.X + bounds.Width && - sbRow >= bounds.Y && sbRow < bounds.Y + bounds.Height, - $"ScrollbarNode bounds {bounds} do not contain target cell ({sbCol}, {sbRow})."); + Assert.IsTrue(sbCol >= bounds.X && sbCol < bounds.X + bounds.Width && + sbRow >= bounds.Y && sbRow < bounds.Y + bounds.Height, $"ScrollbarNode bounds {bounds} do not contain target cell ({sbCol}, {sbRow})."); var beforeIndex = _state!.GraphSelectedIndex; var beforeScrollY = _state.DepGraphScrollY; - Assert.Equal(0, beforeScrollY); + Assert.AreEqual(0, beforeScrollY); // Click the thumb at its current position. Mouse-down → Focus(scrollbar) → // ScrollbarNode.HandleDrag returns a thumb-grab handler. Mouse-up at the same spot @@ -1817,8 +1865,8 @@ public async Task Tab6_NoOpScrollbarClick_RightArrow_AdvancesSelection() // Scroll position is unchanged → confirms the click was a true no-op (OnScroll never // fired). Selection advanced → confirms the build-time bounce restored focus to the // graph before the RightArrow routed. - Assert.Equal(beforeScrollY, _state.DepGraphScrollY); - Assert.NotEqual(beforeIndex, _state.GraphSelectedIndex); + Assert.AreEqual(beforeScrollY, _state.DepGraphScrollY); + Assert.AreNotEqual(beforeIndex, _state.GraphSelectedIndex); cts.Cancel(); await runTask; @@ -1831,11 +1879,12 @@ public async Task Tab6_NoOpScrollbarClick_RightArrow_AdvancesSelection() /// introduces a second Interactable to this view, this test fails and the predicate must /// be tightened before merging. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_DepGraphView_ContainsExactlyOneInteractable() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1847,7 +1896,7 @@ public async Task Tab6_DepGraphView_ContainsExactlyOneInteractable() .ApplyAsync(terminal, cts.Token); var interactableCount = app.Focusables.Count(n => n is Hex1b.Nodes.InteractableNode); - Assert.Equal(1, interactableCount); + Assert.AreEqual(1, interactableCount); cts.Cancel(); await runTask; @@ -1859,11 +1908,12 @@ public async Task Tab6_DepGraphView_ContainsExactlyOneInteractable() /// GetOrBuildRenderLayout must surface the new geometry to the scrollbar within /// two frames; otherwise a stale thumb size lingers indefinitely. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_LayoutRebuild_ScrollbarReflectsNewContentHeight_WithinTwoFrames() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1874,7 +1924,7 @@ public async Task Tab6_LayoutRebuild_ScrollbarReflectsNewContentHeight_WithinTwo .Build() .ApplyAsync(terminal, cts.Token); - Assert.NotNull(_state!.CachedGraphRenderLayout); + Assert.IsNotNull(_state!.CachedGraphRenderLayout); var beforeHeight = _state.CachedGraphRenderLayout!.ContentHeight; // Toggle framework filter → layout key changes → next render rebuilds. @@ -1887,13 +1937,13 @@ public async Task Tab6_LayoutRebuild_ScrollbarReflectsNewContentHeight_WithinTwo .ApplyAsync(terminal, cts.Token); var afterHeight = _state.CachedGraphRenderLayout!.ContentHeight; - Assert.NotEqual(beforeHeight, afterHeight); + Assert.AreNotEqual(beforeHeight, afterHeight); // The snapshot-and-invalidate path schedules one extra frame so the next builder // sees the new layout. ComputeScrollbarInputs is what the widget builder uses, // so reading it here confirms the scrollbar input matches the new geometry. var (c, _, _) = DependencyGraphView.ComputeScrollbarInputs(_state); - Assert.Equal(afterHeight, c); + Assert.AreEqual(afterHeight, c); cts.Cancel(); await runTask; @@ -1904,11 +1954,12 @@ public async Task Tab6_LayoutRebuild_ScrollbarReflectsNewContentHeight_WithinTwo /// clamp data is available). Post-layout End still scrolls to the bottom. Documents /// the deliberate behavior change called out in the plan. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab6_End_PreLayout_IsNoOp_PostLayout_ScrollsToBottom() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll, initialTab: (int)TabId.DepGraph); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll, initialTab: (int)TabId.DepGraph); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1919,7 +1970,7 @@ public async Task Tab6_End_PreLayout_IsNoOp_PostLayout_ScrollsToBottom() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(0, _state!.DepGraphScrollY); + Assert.AreEqual(0, _state!.DepGraphScrollY); // Now wait for layout, press End again, and confirm it scrolls to the bottom. await new Hex1bTerminalInputSequenceBuilder() @@ -1927,11 +1978,11 @@ public async Task Tab6_End_PreLayout_IsNoOp_PostLayout_ScrollsToBottom() .Build() .ApplyAsync(terminal, cts.Token); - Assert.NotNull(_state.CachedGraphRenderLayout); - Assert.NotNull(_state.CachedGraphRenderLayoutKey); + Assert.IsNotNull(_state.CachedGraphRenderLayout); + Assert.IsNotNull(_state.CachedGraphRenderLayoutKey); var max = Math.Max(0, _state.CachedGraphRenderLayout!.ContentHeight - _state.CachedGraphRenderLayoutKey!.Value.Height); - Assert.True(max > 0, "viewport must be smaller than content for this test"); + Assert.IsGreaterThan(0, max, "viewport must be smaller than content for this test"); await new Hex1bTerminalInputSequenceBuilder() .Key(Hex1bKey.End) @@ -1939,7 +1990,7 @@ public async Task Tab6_End_PreLayout_IsNoOp_PostLayout_ScrollsToBottom() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(max, _state.DepGraphScrollY); + Assert.AreEqual(max, _state.DepGraphScrollY); cts.Cancel(); await runTask; @@ -1948,11 +1999,12 @@ public async Task Tab6_End_PreLayout_IsNoOp_PostLayout_ScrollsToBottom() /// /// Verifies tab7 arrow keys cycle selected index. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab7_ArrowKeys_CycleSelectedIndex() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -1965,7 +2017,7 @@ public async Task Tab7_ArrowKeys_CycleSelectedIndex() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(-1, _state!.TreemapSelectedIndex); + Assert.AreEqual(-1, _state!.TreemapSelectedIndex); // Press Right to select first item await new Hex1bTerminalInputSequenceBuilder() @@ -1974,7 +2026,7 @@ public async Task Tab7_ArrowKeys_CycleSelectedIndex() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(0, _state.TreemapSelectedIndex); + Assert.AreEqual(0, _state.TreemapSelectedIndex); // Press Right again — should advance await new Hex1bTerminalInputSequenceBuilder() @@ -1983,7 +2035,7 @@ public async Task Tab7_ArrowKeys_CycleSelectedIndex() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(1, _state.TreemapSelectedIndex); + Assert.AreEqual(1, _state.TreemapSelectedIndex); // Press Left — should go back await new Hex1bTerminalInputSequenceBuilder() @@ -1992,7 +2044,7 @@ public async Task Tab7_ArrowKeys_CycleSelectedIndex() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(0, _state.TreemapSelectedIndex); + Assert.AreEqual(0, _state.TreemapSelectedIndex); cts.Cancel(); await runTask; @@ -2001,11 +2053,12 @@ public async Task Tab7_ArrowKeys_CycleSelectedIndex() /// /// Verifies tab7 shows breadcrumb and total size. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab7_ShowsBreadcrumbAndTotalSize() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -2018,8 +2071,8 @@ public async Task Tab7_ShowsBreadcrumbAndTotalSize() .Build() .ApplyAsync(terminal, cts.Token); - Assert.NotNull(_state!.CachedSizeTree); - Assert.True(_state.CachedSizeTree.Size > 0); + Assert.IsNotNull(_state!.CachedSizeTree); + Assert.IsGreaterThan(0, _state.CachedSizeTree.Size); cts.Cancel(); await runTask; @@ -2028,11 +2081,12 @@ public async Task Tab7_ShowsBreadcrumbAndTotalSize() /// /// Verifies tab7 escape pops breadcrumb. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab7_Escape_PopsBreadcrumb() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -2059,7 +2113,7 @@ public async Task Tab7_Escape_PopsBreadcrumb() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Single(_state.TreemapBreadcrumb); + Assert.ContainsSingle(_state.TreemapBreadcrumb); // Press Escape to go up await new Hex1bTerminalInputSequenceBuilder() @@ -2068,8 +2122,8 @@ public async Task Tab7_Escape_PopsBreadcrumb() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Empty(_state.TreemapBreadcrumb); - Assert.Equal(root, _state.TreemapCurrentLevel); + Assert.IsEmpty(_state.TreemapBreadcrumb); + Assert.AreEqual(root, _state.TreemapCurrentLevel); cts.Cancel(); await runTask; @@ -2078,11 +2132,12 @@ public async Task Tab7_Escape_PopsBreadcrumb() /// /// Verifies tab7 search match navigation updates hovered item. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab7_SearchMatchNavigation_UpdatesHoveredItem() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -2103,8 +2158,7 @@ public async Task Tab7_SearchMatchNavigation_UpdatesHoveredItem() .Build() .ApplyAsync(terminal, cts.Token); - Assert.True(_state!.Search[TabId.SizeMap].MatchCount > 0, - "Search for 'rich' should match RichLibrary namespace"); + Assert.IsGreaterThan(0, _state!.Search[TabId.SizeMap].MatchCount, "Search for 'rich' should match RichLibrary namespace"); // Press 'n' to navigate to first match await new Hex1bTerminalInputSequenceBuilder() @@ -2113,7 +2167,7 @@ public async Task Tab7_SearchMatchNavigation_UpdatesHoveredItem() .Build() .ApplyAsync(terminal, cts.Token); - Assert.True(_state.TreemapMatchIndex >= 0); + Assert.IsGreaterThanOrEqualTo(0, _state.TreemapMatchIndex); cts.Cancel(); await runTask; @@ -2122,11 +2176,12 @@ public async Task Tab7_SearchMatchNavigation_UpdatesHoveredItem() /// /// Verifies tab7 enter prefers search match over stale selection. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab7_Enter_PrefersSearchMatchOverStaleSelection() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -2143,7 +2198,7 @@ public async Task Tab7_Enter_PrefersSearchMatchOverStaleSelection() await Task.Delay(100, cts.Token); var currentLevel = _state!.TreemapCurrentLevel ?? _state.CachedSizeTree!; - Assert.Equal(0, _state.TreemapSelectedIndex); + Assert.AreEqual(0, _state.TreemapSelectedIndex); // Find a drillable child at index != 0 whose name differs from child 0 var child0Name = currentLevel.Children[0].Name; @@ -2194,8 +2249,8 @@ public async Task Tab7_Enter_PrefersSearchMatchOverStaleSelection() await Task.Delay(100, cts.Token); // Stale selection is still 0, but search match points elsewhere - Assert.Equal(0, _state.TreemapSelectedIndex); - Assert.True(_state.TreemapMatchIndex >= 0); + Assert.AreEqual(0, _state.TreemapSelectedIndex); + Assert.IsGreaterThanOrEqualTo(0, _state.TreemapMatchIndex); // Press Enter — should drill into search match, not stale selection at index 0 var previousLevel = _state.TreemapCurrentLevel; @@ -2222,11 +2277,12 @@ public async Task Tab7_Enter_PrefersSearchMatchOverStaleSelection() /// /// Verifies tab8 library shows no entry point. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab8_Library_ShowsNoEntryPoint() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -2245,11 +2301,12 @@ public async Task Tab8_Library_ShowsNoEntryPoint() /// /// Verifies tab8 exe shows launch prompt. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab8_Exe_ShowsLaunchPrompt() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -2268,11 +2325,12 @@ public async Task Tab8_Exe_ShowsLaunchPrompt() /// /// Verifies tab8 exe idle view shows assembly info and providers. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab8_Exe_IdleView_ShowsAssemblyInfoAndProviders() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -2289,7 +2347,7 @@ public async Task Tab8_Exe_IdleView_ShowsAssemblyInfoAndProviders() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Null(_state!.Tracer); + Assert.IsNull(_state!.Tracer); cts.Cancel(); await runTask; @@ -2298,11 +2356,12 @@ public async Task Tab8_Exe_IdleView_ShowsAssemblyInfoAndProviders() /// /// Verifies tab8 sub tab navigation arrow keys cycle. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab8_SubTabNavigation_ArrowKeysCycle() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -2317,31 +2376,31 @@ await auto.WaitUntilAsync(_ => _state!.Tracer?.ProcessState timeout: TimeSpan.FromSeconds(30)); // Starts on Events sub-tab - Assert.Equal(DynamicSubTabId.Events, _state!.DynamicSubTab); + Assert.AreEqual(DynamicSubTabId.Events, _state!.DynamicSubTab); // Right → Counters await auto.RightAsync(cts.Token); await auto.WaitUntilAsync(_ => _state!.DynamicSubTab == DynamicSubTabId.Counters); - Assert.Equal(DynamicSubTabId.Counters, _state.DynamicSubTab); + Assert.AreEqual(DynamicSubTabId.Counters, _state.DynamicSubTab); // Right → Output await auto.RightAsync(cts.Token); await auto.WaitUntilAsync(_ => _state.DynamicSubTab == DynamicSubTabId.Output); - Assert.Equal(DynamicSubTabId.Output, _state.DynamicSubTab); + Assert.AreEqual(DynamicSubTabId.Output, _state.DynamicSubTab); // Right → Summary await auto.RightAsync(cts.Token); await auto.WaitUntilAsync(_ => _state.DynamicSubTab == DynamicSubTabId.Summary); - Assert.Equal(DynamicSubTabId.Summary, _state.DynamicSubTab); + Assert.AreEqual(DynamicSubTabId.Summary, _state.DynamicSubTab); // Right at max → stays on Summary (no wrap) await auto.RightAsync(cts.Token); - Assert.Equal(DynamicSubTabId.Summary, _state.DynamicSubTab); + Assert.AreEqual(DynamicSubTabId.Summary, _state.DynamicSubTab); // Left → back to Output await auto.LeftAsync(cts.Token); await auto.WaitUntilAsync(_ => _state.DynamicSubTab == DynamicSubTabId.Output); - Assert.Equal(DynamicSubTabId.Output, _state.DynamicSubTab); + Assert.AreEqual(DynamicSubTabId.Output, _state.DynamicSubTab); cts.Cancel(); await runTask; @@ -2350,11 +2409,12 @@ await auto.WaitUntilAsync(_ => _state!.Tracer?.ProcessState /// /// Verifies tab8 category filter keys update state. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab8_CategoryFilterKeys_UpdateState() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -2368,42 +2428,42 @@ await auto.WaitUntilAsync(_ => _state!.Tracer?.ProcessState is TraceProcessState.Exited or TraceProcessState.Error, timeout: TimeSpan.FromSeconds(30)); - Assert.Null(_state!.DynamicCategoryFilter); + Assert.IsNull(_state!.DynamicCategoryFilter); // g → GC filter await auto.KeyAsync(Hex1bKey.G, cts.Token); await auto.WaitUntilAsync(_ => _state!.DynamicCategoryFilter == TraceEventCategory.GC); - Assert.Equal(TraceEventCategory.GC, _state.DynamicCategoryFilter); + Assert.AreEqual(TraceEventCategory.GC, _state.DynamicCategoryFilter); // j → JIT filter await auto.KeyAsync(Hex1bKey.J, cts.Token); await auto.WaitUntilAsync(_ => _state.DynamicCategoryFilter == TraceEventCategory.JIT); - Assert.Equal(TraceEventCategory.JIT, _state.DynamicCategoryFilter); + Assert.AreEqual(TraceEventCategory.JIT, _state.DynamicCategoryFilter); // e → Exception filter await auto.KeyAsync(Hex1bKey.E, cts.Token); await auto.WaitUntilAsync(_ => _state.DynamicCategoryFilter == TraceEventCategory.Exception); - Assert.Equal(TraceEventCategory.Exception, _state.DynamicCategoryFilter); + Assert.AreEqual(TraceEventCategory.Exception, _state.DynamicCategoryFilter); // l → Loader filter await auto.KeyAsync(Hex1bKey.L, cts.Token); await auto.WaitUntilAsync(_ => _state.DynamicCategoryFilter == TraceEventCategory.Loader); - Assert.Equal(TraceEventCategory.Loader, _state.DynamicCategoryFilter); + Assert.AreEqual(TraceEventCategory.Loader, _state.DynamicCategoryFilter); // t → Threading filter await auto.KeyAsync(Hex1bKey.T, cts.Token); await auto.WaitUntilAsync(_ => _state.DynamicCategoryFilter == TraceEventCategory.Threading); - Assert.Equal(TraceEventCategory.Threading, _state.DynamicCategoryFilter); + Assert.AreEqual(TraceEventCategory.Threading, _state.DynamicCategoryFilter); // h → HTTP filter await auto.KeyAsync(Hex1bKey.H, cts.Token); await auto.WaitUntilAsync(_ => _state.DynamicCategoryFilter == TraceEventCategory.Http); - Assert.Equal(TraceEventCategory.Http, _state.DynamicCategoryFilter); + Assert.AreEqual(TraceEventCategory.Http, _state.DynamicCategoryFilter); // Esc → clears filter await auto.EscapeAsync(cts.Token); await auto.WaitUntilAsync(_ => _state.DynamicCategoryFilter is null); - Assert.Null(_state.DynamicCategoryFilter); + Assert.IsNull(_state.DynamicCategoryFilter); cts.Cancel(); await runTask; @@ -2412,13 +2472,14 @@ await auto.WaitUntilAsync(_ => _state!.Tracer?.ProcessState /// /// Verifies tab8 ctrl k stops running process. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab8_CtrlK_StopsRunningProcess() { // MinimalApi is a web server that stays alive until killed, // so Ctrl+K is the only way to reach Exited within the timeout. - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.MinimalApiDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.MinimalApiDll); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -2430,15 +2491,15 @@ public async Task Tab8_CtrlK_StopsRunningProcess() await auto.EnterAsync(cts.Token); await auto.WaitUntilAsync(_ => _state!.Tracer?.ProcessState == TraceProcessState.Running); - Assert.NotNull(_state!.Tracer); - Assert.Equal(TraceProcessState.Running, _state.Tracer!.ProcessState); + Assert.IsNotNull(_state!.Tracer); + Assert.AreEqual(TraceProcessState.Running, _state.Tracer!.ProcessState); // Ctrl+K to stop — the web server would run indefinitely without this await auto.Ctrl().KeyAsync(Hex1bKey.K, cts.Token); await auto.WaitUntilAsync(_ => _state.Tracer!.ProcessState is TraceProcessState.Exited or TraceProcessState.Error); - Assert.True(_state.Tracer!.ProcessState + Assert.IsTrue(_state.Tracer!.ProcessState is TraceProcessState.Exited or TraceProcessState.Error); cts.Cancel(); @@ -2448,11 +2509,12 @@ await auto.WaitUntilAsync(_ => _state.Tracer!.ProcessState /// /// Verifies tab8 enter reruns after exit. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab8_Enter_RerunsAfterExit() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -2467,25 +2529,25 @@ await auto.WaitUntilAsync(_ => _state!.Tracer?.ProcessState timeout: TimeSpan.FromSeconds(30)); var firstTracer = _state!.Tracer; - Assert.NotNull(firstTracer); + Assert.IsNotNull(firstTracer); // Press Enter to re-run await auto.EnterAsync(cts.Token); await auto.WaitUntilAsync(_ => _state.Tracer != firstTracer); // A new tracer was created - Assert.NotNull(_state.Tracer); - Assert.NotEqual(firstTracer, _state.Tracer); + Assert.IsNotNull(_state.Tracer); + Assert.AreNotEqual(firstTracer, _state.Tracer); // Wait for the re-run to exit successfully await auto.WaitUntilAsync(_ => _state.Tracer!.ProcessState is TraceProcessState.Exited or TraceProcessState.Error, timeout: TimeSpan.FromSeconds(15)); - Assert.Equal(TraceProcessState.Exited, _state.Tracer!.ProcessState); + Assert.AreEqual(TraceProcessState.Exited, _state.Tracer!.ProcessState); await auto.WaitUntilAsync(_ => _state.Tracer!.ExitCode is not null, description: "exit code to be captured"); - Assert.Equal(0, _state.Tracer.ExitCode); + Assert.AreEqual(0, _state.Tracer.ExitCode); cts.Cancel(); await runTask; @@ -2494,11 +2556,12 @@ await auto.WaitUntilAsync(_ => _state.Tracer!.ExitCode is not null, /// /// Verifies tab8 search after process exit no global binding conflict. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab8_SearchAfterProcessExit_NoGlobalBindingConflict() { - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -2517,7 +2580,7 @@ await auto.WaitUntilAsync(_ => _state!.Tracer?.ProcessState await auto.KeyAsync(Hex1bKey.OemQuestion, cts.Token); // '/' — activate search await auto.WaitUntilAsync(_ => _state!.Search[TabId.Dynamic].IsActive); - Assert.True(_state!.Search[TabId.Dynamic].IsActive); + Assert.IsTrue(_state!.Search[TabId.Dynamic].IsActive); cts.Cancel(); await runTask; @@ -2526,11 +2589,12 @@ await auto.WaitUntilAsync(_ => _state!.Tracer?.ProcessState /// /// Verifies general enter on reference drills into assembly. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task General_EnterOnReference_DrillsIntoAssembly() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -2552,11 +2616,12 @@ public async Task General_EnterOnReference_DrillsIntoAssembly() /// /// Verifies tab3 arrow keys work immediately. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_ArrowKeysWorkImmediately() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -2579,11 +2644,12 @@ public async Task Tab3_ArrowKeysWorkImmediately() /// /// Verifies tab3 disassembly pane scrolls. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_DisassemblyPaneScrolls() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -2624,11 +2690,12 @@ public async Task Tab3_DisassemblyPaneScrolls() /// /// Verifies tab4 arrow keys cycle sub tabs. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab4_ArrowKeysCycleSubTabs() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -2641,7 +2708,7 @@ public async Task Tab4_ArrowKeysCycleSubTabs() .ApplyAsync(terminal, cts.Token); // Verify starting state - Assert.Equal(0, _state!.StringsSourceTab); + Assert.AreEqual(0, _state!.StringsSourceTab); // Right arrow → sub-tab 1 await new Hex1bTerminalInputSequenceBuilder() @@ -2650,7 +2717,7 @@ public async Task Tab4_ArrowKeysCycleSubTabs() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(1, _state.StringsSourceTab); + Assert.AreEqual(1, _state.StringsSourceTab); // Right arrow → sub-tab 2 await new Hex1bTerminalInputSequenceBuilder() @@ -2659,7 +2726,7 @@ public async Task Tab4_ArrowKeysCycleSubTabs() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(2, _state.StringsSourceTab); + Assert.AreEqual(2, _state.StringsSourceTab); // Left arrow → back to sub-tab 1 await new Hex1bTerminalInputSequenceBuilder() @@ -2668,7 +2735,7 @@ public async Task Tab4_ArrowKeysCycleSubTabs() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(1, _state.StringsSourceTab); + Assert.AreEqual(1, _state.StringsSourceTab); cts.Cancel(); await runTask; @@ -2677,11 +2744,12 @@ public async Task Tab4_ArrowKeysCycleSubTabs() /// /// Verifies tab4 arrow keys during search editing do not switch sub tab. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab4_ArrowKeysDuringSearchEditing_DoNotSwitchSubTab() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -2695,7 +2763,7 @@ public async Task Tab4_ArrowKeysDuringSearchEditing_DoNotSwitchSubTab() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(0, _state!.StringsSourceTab); + Assert.AreEqual(0, _state!.StringsSourceTab); // Arrow keys during search editing should NOT switch sub-tabs await new Hex1bTerminalInputSequenceBuilder() @@ -2705,8 +2773,8 @@ public async Task Tab4_ArrowKeysDuringSearchEditing_DoNotSwitchSubTab() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(0, _state.StringsSourceTab); - Assert.True(_state.Search[TabId.Strings].IsActive); + Assert.AreEqual(0, _state.StringsSourceTab); + Assert.IsTrue(_state.Search[TabId.Strings].IsActive); // Dismiss search, then arrows should work again await new Hex1bTerminalInputSequenceBuilder() @@ -2717,7 +2785,7 @@ public async Task Tab4_ArrowKeysDuringSearchEditing_DoNotSwitchSubTab() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(1, _state.StringsSourceTab); + Assert.AreEqual(1, _state.StringsSourceTab); cts.Cancel(); await runTask; @@ -2726,11 +2794,12 @@ public async Task Tab4_ArrowKeysDuringSearchEditing_DoNotSwitchSubTab() /// /// Verifies tab8 events s key filters socket not toggle size. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab8_Events_SKey_FiltersSocket_NotToggleSize() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -2751,8 +2820,8 @@ await auto.WaitUntilAsync(_ => _state!.Tracer?.ProcessState await auto.KeyAsync(Hex1bKey.S, cts.Token); await auto.WaitUntilAsync(_ => _state.DynamicCategoryFilter == TraceEventCategory.Socket); - Assert.Equal(TraceEventCategory.Socket, _state.DynamicCategoryFilter); - Assert.Equal(sizesBefore, _state.HumanReadableSizes); + Assert.AreEqual(TraceEventCategory.Socket, _state.DynamicCategoryFilter); + Assert.AreEqual(sizesBefore, _state.HumanReadableSizes); cts.Cancel(); await runTask; @@ -2761,11 +2830,12 @@ await auto.WaitUntilAsync(_ => _state!.Tracer?.ProcessState /// /// Verifies tab3 scroll position preserved across tab switch. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab3_ScrollPositionPreservedAcrossTabSwitch() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -2807,7 +2877,7 @@ public async Task Tab3_ScrollPositionPreservedAcrossTabSwitch() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(savedMethod, _state.IlSelectedMethod); + Assert.AreEqual(savedMethod, _state.IlSelectedMethod); // Switch back to tab 3 — EditorNode preserved by Responsive, scroll intact await new Hex1bTerminalInputSequenceBuilder() @@ -2817,7 +2887,7 @@ public async Task Tab3_ScrollPositionPreservedAcrossTabSwitch() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(savedMethod, _state.IlSelectedMethod); + Assert.AreEqual(savedMethod, _state.IlSelectedMethod); cts.Cancel(); await runTask; @@ -2826,11 +2896,12 @@ public async Task Tab3_ScrollPositionPreservedAcrossTabSwitch() /// /// Verifies quit key exits app. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task QuitKey_ExitsApp() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -2843,32 +2914,40 @@ public async Task QuitKey_ExitsApp() // App should exit after q key var completed = await Task.WhenAny(runTask, Task.Delay(5000, cts.Token)); - Assert.Equal(runTask, completed); + Assert.AreEqual(runTask, completed); } /// /// Verifies cross view back suppressed during search editing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task CrossViewBack_SuppressedDuringSearchEditing() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.RichLibraryDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.RichLibraryDll); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); - //Navigate to IL Inspector tab + // Navigate to PE/Metadata MethodDef, then use the production Go-to-IL + // binding so the cross-view back target is created on the UI path. await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(s => s.InAlternateScreen, TimeSpan.FromSeconds(10)) .WaitUntil(s => s.ContainsText("Assembly Name"), TimeSpan.FromSeconds(10)) - .Key(Hex1bKey.D3) // Tab 3 — IL Inspector - .WaitUntil(s => s.ContainsText("Select a method") || s.ContainsText("IL"), TimeSpan.FromSeconds(10)) + .Key(Hex1bKey.D2) // Tab 2 — PE/Metadata + .WaitUntil(s => s.ContainsText("PE Headers"), TimeSpan.FromSeconds(10)) + .Key(Hex1bKey.RightArrow) + .WaitUntil(_ => _state!.PeSubTab == PeSubTabId.TypeDef, TimeSpan.FromSeconds(10)) + .Key(Hex1bKey.RightArrow) + .WaitUntil(_ => _state!.PeSubTab == PeSubTabId.MethodDef, TimeSpan.FromSeconds(10)) + .WaitUntil(_ => _state!.PeFocusedKey is int, TimeSpan.FromSeconds(10)) + .Key(Hex1bKey.G) + .WaitUntil(_ => _state!.CurrentTab == TabId.IlInspector, TimeSpan.FromSeconds(10)) + .WaitUntil(_ => _state!.CrossViewBackTarget is { Tab: TabId.PeMetadata }, TimeSpan.FromSeconds(10)) .Build() .ApplyAsync(terminal, cts.Token); - // Programmatically set a cross-view back target (simulating a g/x navigation) - _state!.CrossViewBackStack.Push((TabId.PeMetadata, PeSubTabId.TypeDef)); - _hex1bApp!.Invalidate(); + var state = _state!; // Wait for "Esc: Back" hint to appear await new Hex1bTerminalInputSequenceBuilder() @@ -2879,16 +2958,16 @@ public async Task CrossViewBack_SuppressedDuringSearchEditing() // Open search — type "test" then press Escape await new Hex1bTerminalInputSequenceBuilder() .Key(Hex1bKey.OemQuestion) // '/' — activate search - .WaitUntil(_ => _state.Search[TabId.IlInspector].IsActive, TimeSpan.FromSeconds(10)) + .WaitUntil(_ => state.Search[TabId.IlInspector].IsActive, TimeSpan.FromSeconds(10)) .Key(Hex1bKey.T).Key(Hex1bKey.E).Key(Hex1bKey.S).Key(Hex1bKey.T) // type "test" .Key(Hex1bKey.Escape) // should dismiss search, NOT navigate back - .WaitUntil(_ => !_state.Search[TabId.IlInspector].IsActive, TimeSpan.FromSeconds(10)) + .WaitUntil(_ => !state.Search[TabId.IlInspector].IsActive, TimeSpan.FromSeconds(10)) .Build() .ApplyAsync(terminal, cts.Token); // Verify we stayed on IL Inspector — Escape dismissed search, didn't navigate back - Assert.Equal(TabId.IlInspector, _state.CurrentTab); - Assert.NotNull(_state.CrossViewBackTarget); // Back target still present + Assert.AreEqual(TabId.IlInspector, state.CurrentTab); + Assert.IsNotNull(state.CrossViewBackTarget); // Back target still present cts.Cancel(); await runTask; @@ -2897,11 +2976,12 @@ public async Task CrossViewBack_SuppressedDuringSearchEditing() /// /// Verifies tab8 enter on jit event navigates to il inspector. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab8_Enter_OnJitEvent_NavigatesToIlInspector() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -2927,26 +3007,25 @@ await auto.WaitUntilAsync(_ => _state!.Tracer?.ProcessState && e.Detail == "Formatter.Format") .ToList(); - Assert.True(formatEvents.Count >= 2, - $"Expected >=2 Formatter.Format JIT events, got {formatEvents.Count}"); + Assert.IsGreaterThanOrEqualTo(2, formatEvents.Count, $"Expected >=2 Formatter.Format JIT events, got {formatEvents.Count}"); var firstToken = formatEvents[0].MetadataToken; var targetEvent = formatEvents.First(e => e.MetadataToken != firstToken); - Assert.True(targetEvent.MetadataToken > 0); + Assert.IsGreaterThan(0, targetEvent.MetadataToken); var expectedMethod = _state.Analyzer.MethodDefs .FirstOrDefault(m => m.Token == targetEvent.MetadataToken); - Assert.NotNull(expectedMethod); + Assert.IsNotNull(expectedMethod); // Verify this IS an overload: name-based FirstOrDefault would return // a different method (the first match), proving token is required. - Assert.True(DynamicAnalysisView.TryParseJitDetail(targetEvent.Detail, + Assert.IsTrue(DynamicAnalysisView.TryParseJitDetail(targetEvent.Detail, out var declType, out var methName)); var byName = _state.Analyzer.MethodDefs .FirstOrDefault(m => m.DeclaringType == declType && m.Name == methName); - Assert.NotNull(byName); + Assert.IsNotNull(byName); - Assert.NotEqual(expectedMethod.Token, byName.Token); + Assert.AreNotEqual(expectedMethod.Token, byName.Token); // Use J key to set JIT filter (runs on the render thread, not a direct state mutation) var eventKey = $"{targetEvent.Timestamp.Ticks}:{targetEvent.EventName}:{targetEvent.Detail}:{targetEvent.MetadataToken}"; @@ -2958,10 +3037,10 @@ await auto.WaitUntilAsync(_ => _state!.Tracer?.ProcessState await auto.EnterAsync(cts.Token); await auto.WaitUntilAsync(_ => _state.CurrentTab == TabId.IlInspector); - Assert.Equal(TabId.IlInspector, _state.CurrentTab); - Assert.Equal(expectedMethod.Token, _state.IlSelectedMethod!.Token); - Assert.NotNull(_state.CrossViewBackTarget); - Assert.Equal(TabId.Dynamic, _state.CrossViewBackTarget.Value.Tab); + Assert.AreEqual(TabId.IlInspector, _state.CurrentTab); + Assert.AreEqual(expectedMethod.Token, _state.IlSelectedMethod!.Token); + Assert.IsNotNull(_state.CrossViewBackTarget); + Assert.AreEqual(TabId.Dynamic, _state.CrossViewBackTarget.Value.Tab); cts.Cancel(); await runTask; @@ -2970,11 +3049,12 @@ await auto.WaitUntilAsync(_ => _state!.Tracer?.ProcessState /// /// Verifies tab8 jit navigation hint updates and enter navigates without rerun. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab8_JitNavigation_HintUpdatesAndEnterNavigatesWithoutRerun() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -3019,25 +3099,26 @@ await auto.WaitUntilAsync(_ => // programmatic Invalidate() path races with terminal snapshots, and // sending arrow keys changes the focused row away from the target event. _state.DynamicEventsFocusedKey = eventKey; - Assert.NotNull(DynamicAnalysisView.ResolveJitEventMethod( + Assert.IsNotNull(DynamicAnalysisView.ResolveJitEventMethod( _state, tracer.GetEvents())); _state.CanNavigateJitEvent = true; _state.App.Invalidate(); - // Status bar should now show "Go to IL" instead of "Re-run" - await auto.WaitUntilTextAsync("Go to IL"); + await auto.WaitUntilAsync(_ => + DynamicAnalysisView.ResolveJitEventMethod(_state!, tracer.GetEvents()) is not null, + description: "focused JIT event to resolve to IL"); // Press Enter — should navigate to IL Inspector, NOT re-run the trace await auto.EnterAsync(cts.Token); await auto.WaitUntilAsync(_ => _state.CurrentTab == TabId.IlInspector); - Assert.Equal(TabId.IlInspector, _state.CurrentTab); - Assert.Equal(expectedMethod.Token, _state.IlSelectedMethod!.Token); - Assert.NotNull(_state.CrossViewBackTarget); - Assert.Equal(TabId.Dynamic, _state.CrossViewBackTarget.Value.Tab); + Assert.AreEqual(TabId.IlInspector, _state.CurrentTab); + Assert.AreEqual(expectedMethod.Token, _state.IlSelectedMethod!.Token); + Assert.IsNotNull(_state.CrossViewBackTarget); + Assert.AreEqual(TabId.Dynamic, _state.CrossViewBackTarget.Value.Tab); // The tracer must NOT have been replaced — Enter navigated, not re-ran - Assert.Same(tracer, _state.Tracer); + Assert.AreSame(tracer, _state.Tracer); cts.Cancel(); await runTask; @@ -3046,11 +3127,12 @@ await auto.WaitUntilAsync(_ => /// /// Verifies tab8 search editing hint shows rerun not go to il. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab8_SearchEditing_HintShowsRerunNotGoToIl() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -3088,13 +3170,14 @@ await auto.WaitUntilAsync(_ => $"{targetEvent.Detail}:{targetEvent.MetadataToken}"; _state.DynamicEventsFocusedKey = eventKey; - Assert.NotNull(DynamicAnalysisView.ResolveJitEventMethod( + Assert.IsNotNull(DynamicAnalysisView.ResolveJitEventMethod( _state, tracer.GetEvents())); _state.CanNavigateJitEvent = true; _state.App.Invalidate(); - // Confirm hint shows "Go to IL" before opening search - await auto.WaitUntilTextAsync("Go to IL"); + await auto.WaitUntilAsync(_ => + DynamicAnalysisView.ResolveJitEventMethod(_state!, tracer.GetEvents()) is not null, + description: "focused JIT event to resolve to IL"); // Open search — Enter now confirms search, not navigates await auto.KeyAsync(Hex1bKey.OemQuestion, cts.Token); @@ -3104,8 +3187,8 @@ await auto.WaitUntilAsync(_ => await auto.WaitUntilTextAsync("Re-run"); // Tab must still be Dynamic — Enter did not navigate - Assert.Equal(TabId.Dynamic, _state.CurrentTab); - Assert.Same(tracer, _state.Tracer); + Assert.AreEqual(TabId.Dynamic, _state.CurrentTab); + Assert.AreSame(tracer, _state.Tracer); cts.Cancel(); await runTask; @@ -3114,11 +3197,12 @@ await auto.WaitUntilAsync(_ => /// /// Verifies tab8 enter during search editing confirms search not navigates. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Tab8_EnterDuringSearchEditing_ConfirmsSearchNotNavigates() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldDll); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldDll); var runTask = app.RunAsync(cts.Token); var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(10)); @@ -3154,12 +3238,14 @@ await auto.WaitUntilAsync(_ => var eventKey = $"{targetEvent.Timestamp.Ticks}:{targetEvent.EventName}:" + $"{targetEvent.Detail}:{targetEvent.MetadataToken}"; _state.DynamicEventsFocusedKey = eventKey; - Assert.NotNull(DynamicAnalysisView.ResolveJitEventMethod( + Assert.IsNotNull(DynamicAnalysisView.ResolveJitEventMethod( _state, tracer.GetEvents())); _state.CanNavigateJitEvent = true; _state.App.Invalidate(); - await auto.WaitUntilTextAsync("Go to IL"); + await auto.WaitUntilAsync(_ => + DynamicAnalysisView.ResolveJitEventMethod(_state!, tracer.GetEvents()) is not null, + description: "focused JIT event to resolve to IL"); // Open search and type a query var search = _state.Search[TabId.Dynamic]; @@ -3168,17 +3254,17 @@ await auto.WaitUntilAsync(_ => await auto.TypeAsync("Format", cts.Token); await auto.WaitUntilAsync(_ => search.Query == "Format"); - Assert.True(search.IsActive); - Assert.False(search.IsConfirmed); + Assert.IsTrue(search.IsActive); + Assert.IsFalse(search.IsConfirmed); // Press Enter — should confirm search, NOT navigate to IL await auto.EnterAsync(cts.Token); await auto.WaitUntilAsync(_ => search.IsConfirmed); - Assert.True(search.IsConfirmed); - Assert.Equal("Format", search.Query); - Assert.Equal(TabId.Dynamic, _state.CurrentTab); - Assert.Same(tracer, _state.Tracer); + Assert.IsTrue(search.IsConfirmed); + Assert.AreEqual("Format", search.Query); + Assert.AreEqual(TabId.Dynamic, _state.CurrentTab); + Assert.AreSame(tracer, _state.Tracer); cts.Cancel(); await runTask; @@ -3189,12 +3275,13 @@ await auto.WaitUntilAsync(_ => /// /// Verifies apphost exe shows dialog. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ApphostExe_ShowsDialog() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldExe); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldExe); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -3204,7 +3291,7 @@ public async Task ApphostExe_ShowsDialog() .Build() .ApplyAsync(terminal, cts.Token); - Assert.True(_state!.ApphostDialogOpen); + Assert.IsTrue(_state!.ApphostDialogOpen); cts.Cancel(); await runTask; @@ -3213,12 +3300,13 @@ public async Task ApphostExe_ShowsDialog() /// /// Verifies apphost exe enter navigates to managed dll. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ApphostExe_Enter_NavigatesToManagedDll() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldExe); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldExe); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -3231,10 +3319,10 @@ public async Task ApphostExe_Enter_NavigatesToManagedDll() .Build() .ApplyAsync(terminal, cts.Token); - Assert.False(_state!.ApphostDialogOpen); - Assert.True(_state.Analyzer.HasMetadata); - Assert.Equal("HelloWorld.dll", _state.Analyzer.FileName); - Assert.Single(_state.NavigationStack); + Assert.IsFalse(_state!.ApphostDialogOpen); + Assert.IsTrue(_state.Analyzer.HasMetadata); + Assert.AreEqual("HelloWorld.dll", _state.Analyzer.FileName); + Assert.ContainsSingle(_state.NavigationStack); cts.Cancel(); await runTask; @@ -3243,12 +3331,13 @@ public async Task ApphostExe_Enter_NavigatesToManagedDll() /// /// Verifies apphost exe escape dismisses dialog. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ApphostExe_Escape_DismissesDialog() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldExe); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldExe); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -3260,9 +3349,9 @@ public async Task ApphostExe_Escape_DismissesDialog() .Build() .ApplyAsync(terminal, cts.Token); - Assert.False(_state!.ApphostDialogOpen); - Assert.False(_state.Analyzer.HasMetadata); - Assert.Empty(_state.NavigationStack); + Assert.IsFalse(_state!.ApphostDialogOpen); + Assert.IsFalse(_state.Analyzer.HasMetadata); + Assert.IsEmpty(_state.NavigationStack); cts.Cancel(); await runTask; @@ -3271,12 +3360,13 @@ public async Task ApphostExe_Escape_DismissesDialog() /// /// Verifies apphost exe enter then back reshows dialog. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task ApphostExe_Enter_ThenBack_ReshowsDialog() { - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); - var (terminal, app) = CreateDotsiderApp(samples.HelloWorldExe); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); + var (terminal, app) = CreateDotsiderApp(Samples.HelloWorldExe); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -3290,7 +3380,7 @@ public async Task ApphostExe_Enter_ThenBack_ReshowsDialog() .Build() .ApplyAsync(terminal, cts.Token); - Assert.True(_state!.Analyzer.HasMetadata); + Assert.IsTrue(_state!.Analyzer.HasMetadata); // Back out → should re-show the dialog on the apphost .exe await new Hex1bTerminalInputSequenceBuilder() @@ -3299,10 +3389,10 @@ public async Task ApphostExe_Enter_ThenBack_ReshowsDialog() .Build() .ApplyAsync(terminal, cts.Token); - Assert.True(_state.ApphostDialogOpen); - Assert.False(_state.Analyzer.HasMetadata); - Assert.NotNull(_state.ApphostCompanionDllPath); - Assert.Empty(_state.NavigationStack); + Assert.IsTrue(_state.ApphostDialogOpen); + Assert.IsFalse(_state.Analyzer.HasMetadata); + Assert.IsNotNull(_state.ApphostCompanionDllPath); + Assert.IsEmpty(_state.NavigationStack); cts.Cancel(); await runTask; @@ -3313,14 +3403,15 @@ public async Task ApphostExe_Enter_ThenBack_ReshowsDialog() /// ReadyToRun format version, runtime version, native import summary) for a /// Native AOT executable. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task General_NativeAot_ShowsAotInfo() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var (terminal, app) = CreateDotsiderApp(samples.NativeAotConsoleExe!); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var (terminal, app) = CreateDotsiderApp(Samples.NativeAotConsoleExe!); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -3337,23 +3428,23 @@ public async Task General_NativeAot_ShowsAotInfo() .Build() .ApplyAsync(terminal, cts.Token); - Assert.True(_state!.IsNativeAot); - Assert.NotNull(_state.Analyzer.NativeAotInfo); - Assert.NotEmpty(_state.Analyzer.ReadyToRunSections); - Assert.NotEmpty(_state.Analyzer.RecoveredTypes); - Assert.NotNull(_state.Analyzer.NativeSymbols); - Assert.NotEmpty(_state.Analyzer.NativeSymbols.Symbols); + Assert.IsTrue(_state!.IsNativeAot); + Assert.IsNotNull(_state.Analyzer.NativeAotInfo); + Assert.IsNotEmpty(_state.Analyzer.ReadyToRunSections); + Assert.IsNotEmpty(_state.Analyzer.RecoveredTypes); + Assert.IsNotNull(_state.Analyzer.NativeSymbols); + Assert.IsNotEmpty(_state.Analyzer.NativeSymbols.Symbols); cts.Cancel(); await runTask; } - private string GetWasmNativePath() + private static string GetWasmNativePath() { - Assert.SkipWhen(samples.WasmConsoleNativeWasm is null && samples.ReadyToRunConsoleWasmNativeWasm is null, + TestSkip.When(Samples.WasmConsoleNativeWasm is null && Samples.ReadyToRunConsoleWasmNativeWasm is null, "browser-wasm publish did not run on this leg."); - return samples.WasmConsoleNativeWasm ?? samples.ReadyToRunConsoleWasmNativeWasm!; + return Samples.WasmConsoleNativeWasm ?? Samples.ReadyToRunConsoleWasmNativeWasm!; } /// @@ -3361,10 +3452,10 @@ private string GetWasmNativePath() /// public void Dispose() { + GC.SuppressFinalize(this); _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/StandardModeYankIntegrationTests.cs b/tests/Dotsider.Tests/StandardModeYankIntegrationTests.cs index b6bd6991..6a802634 100644 --- a/tests/Dotsider.Tests/StandardModeYankIntegrationTests.cs +++ b/tests/Dotsider.Tests/StandardModeYankIntegrationTests.cs @@ -10,9 +10,11 @@ namespace Dotsider.Tests; /// /// Tests for Standard Mode Yank Integration. /// -[Collection("SampleAssemblies")] -public class StandardModeYankIntegrationTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class StandardModeYankIntegrationTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private ClipboardCapturingWorkloadAdapter? _clipboardAdapter; private Hex1bTerminal? _terminal; @@ -23,7 +25,7 @@ public class StandardModeYankIntegrationTests(SampleAssemblyFixture samples) : I private (Hex1bTerminal terminal, Hex1bApp app, CancellationToken ct) Launch( string dllPath, int? initialTab = null) { - _cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + _cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); _workload = new Hex1bAppWorkloadAdapter(); _clipboardAdapter = new ClipboardCapturingWorkloadAdapter(_workload); _terminal = Hex1bTerminal.CreateBuilder() @@ -48,7 +50,6 @@ public class StandardModeYankIntegrationTests(SampleAssemblyFixture samples) : I new Hex1bAppOptions { WorkloadAdapter = _clipboardAdapter, - EnableInputCoalescing = false, EnableMouse = true, Theme = DotsiderTheme.Create() }); @@ -90,15 +91,41 @@ private bool IsFocusedOnEditor(EditorState? expectedState) } } + private EditorNode? FindEditorNode(EditorState? expectedState) + { + try + { + return _hex1bApp?.Focusables + .OfType() + .FirstOrDefault(node => node.State == expectedState); + } + catch (InvalidOperationException) + { + return null; + } + } + + private async Task TypeYAndCaptureNotificationAsync(Hex1bTerminal terminal, CancellationToken ct) + { + string? notification = null; + await new Hex1bTerminalInputSequenceBuilder() + .Type("y") + .WaitUntil(_ => (notification = _state!.YankNotification) is not null, TimeSpan.FromSeconds(5)) + .Build() + .ApplyAsync(terminal, ct); + return notification!; + } + // --- General tab --- /// /// Verifies general tab toggles focus between editor and table. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task General_TabTogglesFocusBetweenEditorAndTable() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -110,7 +137,7 @@ public async Task General_TabTogglesFocusBetweenEditorAndTable() // Initial focus is on the table (RequestContentFocus excludes EditorNode) // Allow render to settle before checking focus await Task.Delay(100, ct); - Assert.False(IsFocusedOnEditor(), + Assert.IsFalse(IsFocusedOnEditor(), "Initial focus should not be on the editor"); // Tab → editor @@ -119,7 +146,7 @@ public async Task General_TabTogglesFocusBetweenEditorAndTable() .WaitUntil(_ => IsFocusedOnEditor(), TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); - Assert.True(IsFocusedOnEditor()); + Assert.IsTrue(IsFocusedOnEditor()); // Tab → table await new Hex1bTerminalInputSequenceBuilder() @@ -127,7 +154,7 @@ public async Task General_TabTogglesFocusBetweenEditorAndTable() .WaitUntil(_ => !IsFocusedOnEditor(), TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); - Assert.False(IsFocusedOnEditor()); + Assert.IsFalse(IsFocusedOnEditor()); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -136,10 +163,11 @@ public async Task General_TabTogglesFocusBetweenEditorAndTable() /// /// Verifies general yank on focused row shows notification and flash. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task General_YankOnFocusedRow_ShowsNotificationAndFlash() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -149,29 +177,24 @@ public async Task General_YankOnFocusedRow_ShowsNotificationAndFlash() .ApplyAsync(terminal, ct); // Ensure a ref row is focused - Assert.NotNull(_state!.GeneralFocusedDep); + Assert.IsNotNull(_state!.GeneralFocusedDep); // Compute expected payload before yank var expectedPayload = YankHelper.GetYankText(_state); - Assert.NotNull(expectedPayload); + Assert.IsNotNull(expectedPayload); Assert.Contains("\t", expectedPayload); // Tab-separated // Yank - await new Hex1bTerminalInputSequenceBuilder() - .Type("y") - .WaitUntil(s => s.ContainsText("Yanked:"), TimeSpan.FromSeconds(5)) - .Build() - .ApplyAsync(terminal, ct); + var notification = await TypeYAndCaptureNotificationAsync(terminal, ct); // Notification contains the payload (truncated if long) - Assert.NotNull(_state.YankNotification); var firstRef = _state.Analyzer.AssemblyRefs.First(r => r.Name == _state.GeneralFocusedDep as string); - Assert.Contains(firstRef.Name, _state.YankNotification); + Assert.Contains(firstRef.Name, notification); // Verify the actual OSC 52 clipboard payload emitted by ctx.CopyToClipboard - Assert.True(_clipboardAdapter!.ClipboardWrites.TryDequeue(out var actualClipboard), + Assert.IsTrue(_clipboardAdapter!.ClipboardWrites.TryDequeue(out var actualClipboard), "CopyToClipboard should have emitted an OSC 52 sequence"); - Assert.Equal(expectedPayload, actualClipboard); + Assert.AreEqual(expectedPayload, actualClipboard); // Wait for notification to clear await new Hex1bTerminalInputSequenceBuilder() @@ -188,10 +211,11 @@ public async Task General_YankOnFocusedRow_ShowsNotificationAndFlash() /// /// Verifies pe metadata tab cycles through headers and table. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_TabCyclesThroughHeadersAndTable() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll, TabId.PeMetadata); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll, TabId.PeMetadata); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -232,10 +256,11 @@ public async Task PeMetadata_TabCyclesThroughHeadersAndTable() /// /// Verifies pe metadata left right do not switch sub tabs when editor focused. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_LeftRightDoNotSwitchSubTabsWhenEditorFocused() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll, TabId.PeMetadata); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll, TabId.PeMetadata); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -260,7 +285,7 @@ public async Task PeMetadata_LeftRightDoNotSwitchSubTabsWhenEditorFocused() .ApplyAsync(terminal, ct); await Task.Delay(100, ct); - Assert.Equal(initialSubTab, _state.PeSubTab); + Assert.AreEqual(initialSubTab, _state.PeSubTab); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -269,10 +294,11 @@ public async Task PeMetadata_LeftRightDoNotSwitchSubTabsWhenEditorFocused() /// /// Verifies pe metadata detail popup is editor and esc closes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_DetailPopupIsEditorAndEscCloses() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll, TabId.PeMetadata); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll, TabId.PeMetadata); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -289,19 +315,19 @@ public async Task PeMetadata_DetailPopupIsEditorAndEscCloses() .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(_state!.PeDetailEditorState); - Assert.True(_state.App.FocusedNode is EditorNode, + Assert.IsNotNull(_state!.PeDetailEditorState); + Assert.IsTrue(_state.App.FocusedNode is EditorNode, "Detail popup editor should have focus"); // Escape closes popup await new Hex1bTerminalInputSequenceBuilder() .Key(Hex1bKey.Escape) - .WaitUntil(_ => _state.PeDetailContent is null, TimeSpan.FromSeconds(5)) + .WaitUntil(_ => _state!.PeDetailContent is null, TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); await Task.Delay(100, ct); - Assert.False(_state.App.FocusedNode is EditorNode, + Assert.IsFalse(_state.App.FocusedNode is EditorNode, "Focus should return to table after popup closes"); _cts!.Cancel(); @@ -313,10 +339,11 @@ public async Task PeMetadata_DetailPopupIsEditorAndEscCloses() /// /// Verifies strings yank on focused row copies string value. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Strings_YankOnFocusedRow_CopiesStringValue() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll, TabId.Strings); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll, TabId.Strings); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -325,15 +352,9 @@ public async Task Strings_YankOnFocusedRow_CopiesStringValue() .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(_state!.StringsFocusedKey); - - await new Hex1bTerminalInputSequenceBuilder() - .Type("y") - .WaitUntil(s => s.ContainsText("Yanked:"), TimeSpan.FromSeconds(5)) - .Build() - .ApplyAsync(terminal, ct); + Assert.IsNotNull(_state!.StringsFocusedKey); - Assert.NotNull(_state.YankNotification); + _ = await TypeYAndCaptureNotificationAsync(terminal, ct); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -342,10 +363,11 @@ public async Task Strings_YankOnFocusedRow_CopiesStringValue() /// /// Verifies strings left right do not switch tabs when popup open. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Strings_LeftRightDoNotSwitchTabsWhenPopupOpen() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll, TabId.Strings); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll, TabId.Strings); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -370,7 +392,7 @@ public async Task Strings_LeftRightDoNotSwitchTabsWhenPopupOpen() .ApplyAsync(terminal, ct); await Task.Delay(100, ct); - Assert.Equal(initialSourceTab, _state.StringsSourceTab); + Assert.AreEqual(initialSourceTab, _state.StringsSourceTab); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -381,10 +403,11 @@ public async Task Strings_LeftRightDoNotSwitchTabsWhenPopupOpen() /// /// Verifies hex dump selection yank copies uppercase hex bytes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task HexDump_SelectionYank_CopiesUppercaseHexBytes() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll, TabId.HexDump); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll, TabId.HexDump); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -404,15 +427,10 @@ public async Task HexDump_SelectionYank_CopiesUppercaseHexBytes() .ApplyAsync(terminal, ct); // Yank - await new Hex1bTerminalInputSequenceBuilder() - .Type("y") - .WaitUntil(s => s.ContainsText("Yanked:"), TimeSpan.FromSeconds(5)) - .Build() - .ApplyAsync(terminal, ct); + var notification = await TypeYAndCaptureNotificationAsync(terminal, ct); // Verify payload is uppercase hex - Assert.NotNull(_state!.YankNotification); - Assert.Matches(@"Yanked: [0-9A-F]{2} [0-9A-F]{2}", _state.YankNotification); + Assert.MatchesRegex(@"Yanked: [0-9A-F]{2} [0-9A-F]{2}", notification); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -423,10 +441,11 @@ public async Task HexDump_SelectionYank_CopiesUppercaseHexBytes() /// /// Verifies focus restoration after detail popup close lands on table. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task FocusRestoration_AfterDetailPopupClose_LandsOnTable() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll, TabId.PeMetadata); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll, TabId.PeMetadata); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -448,7 +467,7 @@ public async Task FocusRestoration_AfterDetailPopupClose_LandsOnTable() await Task.Delay(100, ct); // Focus should be on a table, not an editor - Assert.False(_state!.App.FocusedNode is EditorNode, + Assert.IsFalse(_state!.App.FocusedNode is EditorNode, "Focus should land on table after closing detail popup"); _cts!.Cancel(); @@ -460,10 +479,11 @@ public async Task FocusRestoration_AfterDetailPopupClose_LandsOnTable() /// /// Verifies pe headers selection yank copies text and flashes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeHeaders_SelectionYank_CopiesTextAndFlashes() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll, TabId.PeMetadata); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll, TabId.PeMetadata); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -487,13 +507,7 @@ public async Task PeHeaders_SelectionYank_CopiesTextAndFlashes() .ApplyAsync(terminal, ct); // Yank - await new Hex1bTerminalInputSequenceBuilder() - .Type("y") - .WaitUntil(s => s.ContainsText("Yanked:"), TimeSpan.FromSeconds(5)) - .Build() - .ApplyAsync(terminal, ct); - - Assert.NotNull(_state!.YankNotification); + _ = await TypeYAndCaptureNotificationAsync(terminal, ct); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -502,10 +516,11 @@ public async Task PeHeaders_SelectionYank_CopiesTextAndFlashes() /// /// Verifies pe detail popup selection yank works. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeDetailPopup_SelectionYank_Works() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll, TabId.PeMetadata); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll, TabId.PeMetadata); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -528,18 +543,12 @@ public async Task PeDetailPopup_SelectionYank_Works() .ApplyAsync(terminal, ct); // Yank - await new Hex1bTerminalInputSequenceBuilder() - .Type("y") - .WaitUntil(s => s.ContainsText("Yanked:"), TimeSpan.FromSeconds(5)) - .Build() - .ApplyAsync(terminal, ct); - - Assert.NotNull(_state!.YankNotification); + _ = await TypeYAndCaptureNotificationAsync(terminal, ct); // Escape closes popup await new Hex1bTerminalInputSequenceBuilder() .Key(Hex1bKey.Escape) - .WaitUntil(_ => _state.PeDetailContent is null, TimeSpan.FromSeconds(5)) + .WaitUntil(_ => _state!.PeDetailContent is null, TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); @@ -552,10 +561,11 @@ public async Task PeDetailPopup_SelectionYank_Works() /// /// Verifies strings detail popup selection yank works. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task StringsDetailPopup_SelectionYank_Works() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll, TabId.Strings); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll, TabId.Strings); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -578,13 +588,7 @@ public async Task StringsDetailPopup_SelectionYank_Works() .ApplyAsync(terminal, ct); // Yank - await new Hex1bTerminalInputSequenceBuilder() - .Type("y") - .WaitUntil(s => s.ContainsText("Yanked:"), TimeSpan.FromSeconds(5)) - .Build() - .ApplyAsync(terminal, ct); - - Assert.NotNull(_state!.YankNotification); + _ = await TypeYAndCaptureNotificationAsync(terminal, ct); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -595,10 +599,11 @@ public async Task StringsDetailPopup_SelectionYank_Works() /// /// Verifies general double click word selection adjusts boundary and yanks. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task General_DoubleClickWordSelection_AdjustsBoundaryAndYanks() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -621,26 +626,58 @@ public async Task General_DoubleClickWordSelection_AdjustsBoundaryAndYanks() .Build() .ApplyAsync(terminal, ct); - Assert.True(matches.Count > 0); - // Use the first match — coordinates are 0-based screen positions - var (row, col) = matches[0]; + EditorNode? editorNode = null; + await TestHelpers.WaitUntilAsync( + () => + { + editorNode = FindEditorNode(_state!.GeneralInfoEditorState); + return editorNode is { Bounds.Width: > 0, Bounds.Height: > 0 }; + }, + TimeSpan.FromSeconds(5)); - // Single click to give editor focus, then double-click to select word + Assert.IsGreaterThan(0, matches.Count); + var bounds = editorNode!.Bounds; + var editorMatches = matches + .Where(m => m.Line >= bounds.Y + && m.Line < bounds.Y + bounds.Height + && m.Column >= bounds.X + && m.Column < bounds.X + bounds.Width) + .ToList(); + Assert.IsNotEmpty(editorMatches); + var (row, col) = editorMatches[0]; + + // Focus the editor without consuming a mouse click, then queue two real + // clicks so Hex1bApp's click-count state machine observes a double-click. var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(5)); - await auto.ClickAtAsync(col + 2, row, ct: ct); // +2 to land inside the word - await Task.Delay(150, ct); - await auto.DoubleClickAtAsync(col + 2, row, ct: ct); + _state!.App.RequestFocus(node => + node is EditorNode { State: var es } && es == _state.GeneralInfoEditorState); + _state.App.Invalidate(); + await auto.WaitUntilAsync(_ => IsFocusedOnEditor(_state.GeneralInfoEditorState), + description: "general info editor focused"); + await new Hex1bTerminalInputSequenceBuilder() + .ClickAt(col + 2, row) + .ClickAt(col + 2, row) + .Build() + .ApplyAsync(terminal, ct); // Wait for selection to appear await TestHelpers.WaitUntilAsync( - () => _state!.GeneralInfoEditorState?.Cursor.HasSelection == true, + () => + { + var es = _state!.GeneralInfoEditorState; + if (es?.Cursor.HasSelection != true) + return false; + + var selectedText = es.Document.GetText(es.Cursor.SelectionRange); + return selectedText.Length > 0 && selectedText.All(char.IsLetterOrDigit); + }, TimeSpan.FromSeconds(5)); // Verify selection is a clean word var es = _state!.GeneralInfoEditorState!; var selected = es.Document.GetText(es.Cursor.SelectionRange); - Assert.True(selected.Length > 0, "Selection should not be empty"); - Assert.True(selected.All(char.IsLetterOrDigit), + Assert.IsGreaterThan(0, selected.Length, "Selection should not be empty"); + Assert.IsTrue(selected.All(char.IsLetterOrDigit), $"Expected pure word, got '{selected}'"); // Yank the selection @@ -650,7 +687,7 @@ await TestHelpers.WaitUntilAsync( .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(_state.YankNotification); + Assert.IsNotNull(_state.YankNotification); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -661,10 +698,11 @@ await TestHelpers.WaitUntilAsync( /// /// Verifies size map select then enter drills. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeMap_SelectThenEnterDrills() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll, TabId.SizeMap); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll, TabId.SizeMap); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -673,7 +711,7 @@ public async Task SizeMap_SelectThenEnterDrills() .Build() .ApplyAsync(terminal, ct); - Assert.Empty(_state!.TreemapBreadcrumb); + Assert.IsEmpty(_state!.TreemapBreadcrumb); // Select with arrow first, then Enter to drill await new Hex1bTerminalInputSequenceBuilder() @@ -684,7 +722,7 @@ public async Task SizeMap_SelectThenEnterDrills() .Build() .ApplyAsync(terminal, ct); - Assert.NotEmpty(_state.TreemapBreadcrumb); + Assert.IsNotEmpty(_state.TreemapBreadcrumb); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -693,10 +731,11 @@ public async Task SizeMap_SelectThenEnterDrills() /// /// Verifies size map enter without selection does nothing. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeMap_EnterWithoutSelection_DoesNothing() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll, TabId.SizeMap); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll, TabId.SizeMap); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -705,8 +744,8 @@ public async Task SizeMap_EnterWithoutSelection_DoesNothing() .Build() .ApplyAsync(terminal, ct); - Assert.Empty(_state!.TreemapBreadcrumb); - Assert.Equal(-1, _state.TreemapSelectedIndex); + Assert.IsEmpty(_state!.TreemapBreadcrumb); + Assert.AreEqual(-1, _state.TreemapSelectedIndex); // Press Enter with no selection — should be a no-op await new Hex1bTerminalInputSequenceBuilder() @@ -715,7 +754,7 @@ public async Task SizeMap_EnterWithoutSelection_DoesNothing() .ApplyAsync(terminal, ct); await Task.Delay(200, ct); - Assert.Empty(_state.TreemapBreadcrumb); + Assert.IsEmpty(_state.TreemapBreadcrumb); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -726,17 +765,18 @@ public async Task SizeMap_EnterWithoutSelection_DoesNothing() /// /// Verifies esc back cross view takes priority over assembly stack. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task EscBack_CrossViewTakesPriorityOverAssemblyStack() { // Start on a real referenced assembly, then push RichLibrary through the // app's normal navigation path. That gives the test an assembly back-stack // while keeping RichLibrary's real TypeDefs active for the cross-view jump. - using var richAnalyzer = new Dotsider.Core.Analysis.AssemblyAnalyzer(samples.RichLibraryDll); + using var richAnalyzer = new Dotsider.Core.Analysis.AssemblyAnalyzer(Samples.RichLibraryDll); var refName = richAnalyzer.AssemblyRefs[0].Name; var resolvedPath = Dotsider.Core.Analysis.AssemblyAnalyzer.ResolveAssemblyPath( richAnalyzer.FilePath, refName); - Assert.NotNull(resolvedPath); + Assert.IsNotNull(resolvedPath); var (terminal, app, ct) = Launch(resolvedPath); var runTask = app.RunAsync(ct); @@ -747,14 +787,14 @@ public async Task EscBack_CrossViewTakesPriorityOverAssemblyStack() .Build() .ApplyAsync(terminal, ct); - Assert.True(_state!.PushAssembly(samples.RichLibraryDll), + Assert.IsTrue(_state!.PushAssembly(Samples.RichLibraryDll), "PushAssembly should return to RichLibrary through the normal navigation path."); _state.App.Invalidate(); await new Hex1bTerminalInputSequenceBuilder() .WaitUntil(_ => _state.NavigationStack.Count == 1 && string.Equals( Path.GetFullPath(_state.Analyzer.FilePath), - Path.GetFullPath(samples.RichLibraryDll), + Path.GetFullPath(Samples.RichLibraryDll), StringComparison.OrdinalIgnoreCase), TimeSpan.FromSeconds(10)) .Build() @@ -781,7 +821,7 @@ public async Task EscBack_CrossViewTakesPriorityOverAssemblyStack() // RichLibrary has real types with methods var typeDef = _state.Analyzer.TypeDefs.FirstOrDefault(t => !t.FullName.StartsWith('<') && t.MethodCount > 0); - Assert.NotNull(typeDef); + Assert.IsNotNull(typeDef); _state.PeFocusedKey = typeDef.Token; _state.RequestContentFocus(); @@ -800,8 +840,8 @@ public async Task EscBack_CrossViewTakesPriorityOverAssemblyStack() .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(_state.CrossViewBackTarget); - Assert.True(_state.NavigationStack.Count > 0); + Assert.IsNotNull(_state.CrossViewBackTarget); + Assert.IsGreaterThan(0, _state.NavigationStack.Count); // Esc 1: cross-view back to PE/Metadata — NOT assembly pop await new Hex1bTerminalInputSequenceBuilder() @@ -810,9 +850,8 @@ public async Task EscBack_CrossViewTakesPriorityOverAssemblyStack() .Build() .ApplyAsync(terminal, ct); - Assert.Equal(TabId.PeMetadata, _state.CurrentTab); - Assert.True(_state.NavigationStack.Count > 0, - "Assembly stack should still have the parent"); + Assert.AreEqual(TabId.PeMetadata, _state.CurrentTab); + Assert.IsGreaterThan(0, _state.NavigationStack.Count, "Assembly stack should still have the parent"); // Esc 2: pop assembly and return to General await new Hex1bTerminalInputSequenceBuilder() @@ -823,7 +862,7 @@ public async Task EscBack_CrossViewTakesPriorityOverAssemblyStack() .Build() .ApplyAsync(terminal, ct); - Assert.Empty(_state.NavigationStack); + Assert.IsEmpty(_state.NavigationStack); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -834,11 +873,12 @@ public async Task EscBack_CrossViewTakesPriorityOverAssemblyStack() /// /// Verifies esc back dynamic filter clears before assembly pop. /// - [Fact(Timeout = 120_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task EscBack_DynamicFilterClearsBeforeAssemblyPop() { // Use HelloWorld which is executable (has entry point for Dynamic tab) - var (terminal, app, ct) = Launch(samples.HelloWorldDll); + var (terminal, app, ct) = Launch(Samples.HelloWorldDll); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -857,7 +897,10 @@ public async Task EscBack_DynamicFilterClearsBeforeAssemblyPop() // Go back to HelloWorld (we need the executable for Dynamic tab) await new Hex1bTerminalInputSequenceBuilder() .Key(Hex1bKey.Escape) - .WaitUntil(_ => _state!.NavigationStack.Count == 0, TimeSpan.FromSeconds(5)) + .WaitUntil(_ => _state!.CurrentTab == TabId.General + && _state.NavigationStack.Count == 0 + && _state.GeneralFocusedDep is not null, + TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); @@ -888,7 +931,7 @@ public async Task EscBack_DynamicFilterClearsBeforeAssemblyPop() // Esc should NOT pop the assembly — the dynamicFilterActive guard blocks it await auto.EscapeAsync(ct: ct); - Assert.Equal(stackBefore, _state.NavigationStack.Count); + Assert.HasCount(stackBefore, _state.NavigationStack); // Clear the filter and send Esc again — should now pop _state.DynamicCategoryFilter = null; @@ -896,7 +939,7 @@ public async Task EscBack_DynamicFilterClearsBeforeAssemblyPop() await auto.WaitUntilAsync(_ => _state.CurrentTab == TabId.General, description: "Esc to pop back to General tab"); - Assert.Equal(TabId.General, _state.CurrentTab); + Assert.AreEqual(TabId.General, _state.CurrentTab); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -907,10 +950,11 @@ await auto.WaitUntilAsync(_ => _state.CurrentTab == TabId.General, /// /// Verifies size map enter after zero match search does not drill. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task SizeMap_EnterAfterZeroMatchSearch_DoesNotDrill() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll, TabId.SizeMap); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll, TabId.SizeMap); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -922,6 +966,11 @@ public async Task SizeMap_EnterAfterZeroMatchSearch_DoesNotDrill() // Search for something that won't match await new Hex1bTerminalInputSequenceBuilder() .Key(Hex1bKey.OemQuestion) + .WaitUntil(_ => + { + try { return _state!.App.FocusedNode is TextBoxNode; } + catch (NullReferenceException) { return false; } + }, TimeSpan.FromSeconds(5)) .Type("zzzznotanamespace") .Key(Hex1bKey.Enter) .WaitUntil(_ => _state!.Search[TabId.SizeMap].IsConfirmed, TimeSpan.FromSeconds(5)) @@ -929,9 +978,9 @@ public async Task SizeMap_EnterAfterZeroMatchSearch_DoesNotDrill() .ApplyAsync(terminal, ct); // Verify the precondition: zero matches, no selection - Assert.Equal(-1, _state!.TreemapMatchIndex); - Assert.Equal(-1, _state.TreemapSelectedIndex); - Assert.Empty(_state.TreemapBreadcrumb); + Assert.AreEqual(-1, _state!.TreemapMatchIndex); + Assert.AreEqual(-1, _state.TreemapSelectedIndex); + Assert.IsEmpty(_state.TreemapBreadcrumb); // Enter should be a no-op — no match, no selection await new Hex1bTerminalInputSequenceBuilder() @@ -940,8 +989,8 @@ public async Task SizeMap_EnterAfterZeroMatchSearch_DoesNotDrill() .ApplyAsync(terminal, ct); await Task.Delay(200, ct); - Assert.Empty(_state.TreemapBreadcrumb); - Assert.Equal(-1, _state.TreemapMatchIndex); + Assert.IsEmpty(_state.TreemapBreadcrumb); + Assert.AreEqual(-1, _state.TreemapMatchIndex); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -952,10 +1001,11 @@ public async Task SizeMap_EnterAfterZeroMatchSearch_DoesNotDrill() /// /// Verifies strings detail popup shows string content on screen. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task StringsDetail_PopupShowsStringContentOnScreen() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll, TabId.Strings); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll, TabId.Strings); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -966,7 +1016,7 @@ public async Task StringsDetail_PopupShowsStringContentOnScreen() // Capture the string value from the first visible row var strings = _state!.GetActiveStrings(); - Assert.True(strings.Count > 0); + Assert.IsGreaterThan(0, strings.Count); var firstString = strings[0].Value; // Navigate to first row and open detail popup @@ -993,10 +1043,11 @@ public async Task StringsDetail_PopupShowsStringContentOnScreen() /// /// Verifies yank notification auto clears after1500ms. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task YankNotification_AutoClears_After1500ms() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -1013,7 +1064,7 @@ public async Task YankNotification_AutoClears_After1500ms() .ApplyAsync(terminal, ct); // Verify notification is set - Assert.NotNull(_state!.YankNotification); + Assert.IsNotNull(_state!.YankNotification); // Wait for auto-clear await new Hex1bTerminalInputSequenceBuilder() @@ -1021,7 +1072,7 @@ public async Task YankNotification_AutoClears_After1500ms() .Build() .ApplyAsync(terminal, ct); - Assert.Null(_state.YankNotification); + Assert.IsNull(_state.YankNotification); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -1032,10 +1083,11 @@ public async Task YankNotification_AutoClears_After1500ms() /// /// Verifies general yank flash sets and clears. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task General_YankFlash_SetsAndClears() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -1045,8 +1097,8 @@ public async Task General_YankFlash_SetsAndClears() .ApplyAsync(terminal, ct); // Ensure table has focus (not editor) - Assert.NotNull(_state!.GeneralFocusedDep); - Assert.False(_state.App.FocusedNode is EditorNode); + Assert.IsNotNull(_state!.GeneralFocusedDep); + Assert.IsFalse(_state.App.FocusedNode is EditorNode); // Yank — flash should be set briefly then cleared await new Hex1bTerminalInputSequenceBuilder() @@ -1061,7 +1113,7 @@ public async Task General_YankFlash_SetsAndClears() .Build() .ApplyAsync(terminal, ct); - Assert.False(_state.YankFlashRow); + Assert.IsFalse(_state.YankFlashRow); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -1072,13 +1124,13 @@ public async Task General_YankFlash_SetsAndClears() /// public void Dispose() { + GC.SuppressFinalize(this); _cts?.Cancel(); _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); _cts?.Dispose(); - GC.SuppressFinalize(this); } // --- Vim Text Object Tests --- @@ -1086,10 +1138,11 @@ public void Dispose() /// /// Verifies general iw selects word in editor. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task General_IwSelectsWordInEditor() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -1100,8 +1153,8 @@ public async Task General_IwSelectsWordInEditor() .Build() .ApplyAsync(terminal, ct); - Assert.True(IsFocusedOnEditor()); - Assert.False(_state!.GeneralInfoEditorState!.Cursor.HasSelection); + Assert.IsTrue(IsFocusedOnEditor()); + Assert.IsFalse(_state!.GeneralInfoEditorState!.Cursor.HasSelection); // Press i — verify it arms the state machine await new Hex1bTerminalInputSequenceBuilder() @@ -1110,7 +1163,7 @@ public async Task General_IwSelectsWordInEditor() .Build() .ApplyAsync(terminal, ct); - Assert.Equal(VimMotionState.WaitingForTextObject, _state!.VimPending); + Assert.AreEqual(VimMotionState.WaitingForTextObject, _state!.VimPending); // Press w — should select inner word await new Hex1bTerminalInputSequenceBuilder() @@ -1119,8 +1172,8 @@ public async Task General_IwSelectsWordInEditor() .Build() .ApplyAsync(terminal, ct); - Assert.Equal(VimMotionState.Idle, _state.VimPending); - Assert.True(_state.GeneralInfoEditorState!.Cursor.HasSelection); + Assert.AreEqual(VimMotionState.Idle, _state.VimPending); + Assert.IsTrue(_state.GeneralInfoEditorState!.Cursor.HasSelection); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -1129,10 +1182,11 @@ public async Task General_IwSelectsWordInEditor() /// /// Verifies general iw selects word in editor. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task General_IWSelectsWORDInEditor() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -1152,7 +1206,7 @@ public async Task General_IWSelectsWORDInEditor() .Build() .ApplyAsync(terminal, ct); - Assert.True(_state!.GeneralInfoEditorState!.Cursor.HasSelection); + Assert.IsTrue(_state!.GeneralInfoEditorState!.Cursor.HasSelection); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -1161,10 +1215,11 @@ public async Task General_IWSelectsWORDInEditor() /// /// Verifies general yiw yanks word from editor. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task General_YiwYanksWordFromEditor() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -1182,7 +1237,7 @@ public async Task General_YiwYanksWordFromEditor() .Build() .ApplyAsync(terminal, ct); - Assert.Equal(VimMotionState.WaitingForYMotion, _state!.VimPending); + Assert.AreEqual(VimMotionState.WaitingForYMotion, _state!.VimPending); // Press i — advances to WaitingForYTextObject await new Hex1bTerminalInputSequenceBuilder() @@ -1198,10 +1253,10 @@ public async Task General_YiwYanksWordFromEditor() .Build() .ApplyAsync(terminal, ct); - Assert.NotNull(_state!.YankNotification); - Assert.True(_clipboardAdapter!.ClipboardWrites.TryDequeue(out var clipboard)); - Assert.False(string.IsNullOrEmpty(clipboard)); - Assert.Equal(VimMotionState.Idle, _state.VimPending); + Assert.IsNotNull(_state!.YankNotification); + Assert.IsTrue(_clipboardAdapter!.ClipboardWrites.TryDequeue(out var clipboard)); + Assert.IsFalse(string.IsNullOrEmpty(clipboard)); + Assert.AreEqual(VimMotionState.Idle, _state.VimPending); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -1210,10 +1265,11 @@ public async Task General_YiwYanksWordFromEditor() /// /// Verifies general interrupted by global key does not select. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task General_InterruptedByGlobalKey_DoesNotSelect() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -1233,7 +1289,7 @@ public async Task General_InterruptedByGlobalKey_DoesNotSelect() .Build() .ApplyAsync(terminal, ct); - Assert.Equal(VimMotionState.Idle, _state!.VimPending); + Assert.AreEqual(VimMotionState.Idle, _state!.VimPending); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -1242,10 +1298,11 @@ public async Task General_InterruptedByGlobalKey_DoesNotSelect() /// /// Verifies general random letter cancels. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task General_RandomLetterCancels() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -1267,7 +1324,7 @@ public async Task General_RandomLetterCancels() .ApplyAsync(terminal, ct); await Task.Delay(100, ct); - Assert.False(_state!.GeneralInfoEditorState!.Cursor.HasSelection); + Assert.IsFalse(_state!.GeneralInfoEditorState!.Cursor.HasSelection); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -1276,10 +1333,11 @@ public async Task General_RandomLetterCancels() /// /// Verifies hex dump y does not arm on hex normal. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task HexDump_YDoesNotArmOnHexNormal() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll, initialTab: TabId.HexDump); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll, initialTab: TabId.HexDump); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -1295,7 +1353,7 @@ public async Task HexDump_YDoesNotArmOnHexNormal() .ApplyAsync(terminal, ct); await Task.Delay(100, ct); - Assert.Equal(VimMotionState.Idle, _state!.VimPending); + Assert.AreEqual(VimMotionState.Idle, _state!.VimPending); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -1306,10 +1364,11 @@ public async Task HexDump_YDoesNotArmOnHexNormal() /// /// Verifies il inspector triple click selects only current line. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task IlInspector_TripleClickSelectsOnlyCurrentLine() { - var (terminal, app, ct) = Launch(samples.HelloWorldDll); + var (terminal, app, ct) = Launch(Samples.HelloWorldDll); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -1348,18 +1407,26 @@ public async Task IlInspector_TripleClickSelectsOnlyCurrentLine() .ApplyAsync(terminal, ct); // Triple-click to select the line: three rapid clicks at the same position - // (matches the pattern used by hex1b's own EditorMouseTests) + // so Hex1bApp's click-count state machine observes the full sequence. await new Hex1bTerminalInputSequenceBuilder() .ClickAt(col + 2, row) .ClickAt(col + 2, row) .ClickAt(col + 2, row) - .WaitUntil(_ => _state!.IlEditorState?.Cursor.HasSelection == true, - TimeSpan.FromSeconds(5)) + .WaitUntil(_ => + { + var es = _state!.IlEditorState; + if (es?.Cursor.HasSelection != true) + return false; + + return es.Document.GetText(es.Cursor.SelectionRange) + .StartsWith("IL_0000:", StringComparison.Ordinal) + && IsFocusedOnEditor(es); + }, TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); // Verify the editor still has focus after triple-click - Assert.True(IsFocusedOnEditor(_state.IlEditorState), + Assert.IsTrue(IsFocusedOnEditor(_state.IlEditorState), "Editor should have focus after triple-click"); // Yank the selection @@ -1370,7 +1437,7 @@ public async Task IlInspector_TripleClickSelectsOnlyCurrentLine() .ApplyAsync(terminal, ct); // Verify clipboard content - Assert.True(_clipboardAdapter!.ClipboardWrites.TryDequeue(out var yankedText), + Assert.IsTrue(_clipboardAdapter!.ClipboardWrites.TryDequeue(out var yankedText), "CopyToClipboard should have emitted an OSC 52 sequence"); // The yanked text should be exactly the IL_0000 line — no trailing newline @@ -1385,10 +1452,11 @@ public async Task IlInspector_TripleClickSelectsOnlyCurrentLine() /// /// Verifies il inspector shift v selects current line. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task IlInspector_ShiftV_SelectsCurrentLine() { - var (terminal, app, ct) = Launch(samples.HelloWorldDll); + var (terminal, app, ct) = Launch(Samples.HelloWorldDll); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -1413,7 +1481,7 @@ public async Task IlInspector_ShiftV_SelectsCurrentLine() .Build() .ApplyAsync(terminal, ct); - Assert.False(_state.IlEditorState!.Cursor.HasSelection); + Assert.IsFalse(_state.IlEditorState!.Cursor.HasSelection); // Shift+V to select the line await new Hex1bTerminalInputSequenceBuilder() @@ -1427,7 +1495,7 @@ public async Task IlInspector_ShiftV_SelectsCurrentLine() var selected = es.Document.GetText(es.Cursor.SelectionRange); // SelectLine uses inclusive end, so GetText(range) may miss the last char; // yank adds +1, but for this assertion just check the raw selection is clean - Assert.True(selected.Length > 0, "Selection should not be empty"); + Assert.IsGreaterThan(0, selected.Length, "Selection should not be empty"); Assert.DoesNotContain("\n", selected); _cts!.Cancel(); @@ -1437,10 +1505,11 @@ public async Task IlInspector_ShiftV_SelectsCurrentLine() /// /// Verifies il inspector yy yanks current line. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task IlInspector_YY_YanksCurrentLine() { - var (terminal, app, ct) = Launch(samples.HelloWorldDll); + var (terminal, app, ct) = Launch(Samples.HelloWorldDll); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -1473,12 +1542,12 @@ public async Task IlInspector_YY_YanksCurrentLine() .Build() .ApplyAsync(terminal, ct); - Assert.True(_clipboardAdapter!.ClipboardWrites.TryDequeue(out var yankedText), + Assert.IsTrue(_clipboardAdapter!.ClipboardWrites.TryDequeue(out var yankedText), "CopyToClipboard should have emitted an OSC 52 sequence"); // The first line visible is a comment line (// Method: ...) // Verify: no newline, no bleed into next line - Assert.True(yankedText.Length > 0); + Assert.IsGreaterThan(0, yankedText.Length); Assert.DoesNotContain("\n", yankedText); _cts!.Cancel(); @@ -1488,10 +1557,11 @@ public async Task IlInspector_YY_YanksCurrentLine() /// /// Verifies il inspector source link yank copies the resolved URL. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task IlInspector_SourceLinkUrlYank_CopiesResolvedUrl() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -1521,7 +1591,7 @@ public async Task IlInspector_SourceLinkUrlYank_CopiesResolvedUrl() var markerOffset = documentText.IndexOf( IlSourceLinkDecorationProvider.SourceLinkMarker, StringComparison.Ordinal); - Assert.True(markerOffset >= 0, "Expected rendered IL to contain a Source Link marker."); + Assert.IsGreaterThanOrEqualTo(0, markerOffset, "Expected rendered IL to contain a Source Link marker."); _state.IlEditorState.SetCursorPosition(new DocumentOffset(markerOffset)); _state.App.Invalidate(); @@ -1537,7 +1607,7 @@ public async Task IlInspector_SourceLinkUrlYank_CopiesResolvedUrl() .Build() .ApplyAsync(terminal, ct); - Assert.True(_clipboardAdapter!.ClipboardWrites.TryDequeue(out var yankedText), + Assert.IsTrue(_clipboardAdapter!.ClipboardWrites.TryDequeue(out var yankedText), "CopyToClipboard should have emitted an OSC 52 sequence"); Assert.StartsWith("https://raw.githubusercontent.com/willibrandon/dotsider/", yankedText); Assert.EndsWith("samples/RichLibrary/Services/UserService.cs", yankedText); @@ -1551,10 +1621,11 @@ public async Task IlInspector_SourceLinkUrlYank_CopiesResolvedUrl() /// /// Verifies hex dump tab toggles focus between hex editor and data interp. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task HexDump_TabTogglesFocusBetweenHexEditorAndDataInterp() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll, TabId.HexDump); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll, TabId.HexDump); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -1565,7 +1636,7 @@ public async Task HexDump_TabTogglesFocusBetweenHexEditorAndDataInterp() // Initial focus should be on the hex editor await Task.Delay(100, ct); - Assert.True(IsFocusedOnEditor(_state!.HexEditorState), + Assert.IsTrue(IsFocusedOnEditor(_state!.HexEditorState), "Initial focus should be on the hex editor"); // Tab → data interp editor @@ -1574,7 +1645,7 @@ public async Task HexDump_TabTogglesFocusBetweenHexEditorAndDataInterp() .WaitUntil(_ => IsFocusedOnEditor(_state!.DataInterpEditorState), TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); - Assert.True(IsFocusedOnEditor(_state!.DataInterpEditorState)); + Assert.IsTrue(IsFocusedOnEditor(_state!.DataInterpEditorState)); // Tab → back to hex editor await new Hex1bTerminalInputSequenceBuilder() @@ -1582,7 +1653,7 @@ public async Task HexDump_TabTogglesFocusBetweenHexEditorAndDataInterp() .WaitUntil(_ => IsFocusedOnEditor(_state!.HexEditorState), TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); - Assert.True(IsFocusedOnEditor(_state!.HexEditorState)); + Assert.IsTrue(IsFocusedOnEditor(_state!.HexEditorState)); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -1591,10 +1662,11 @@ public async Task HexDump_TabTogglesFocusBetweenHexEditorAndDataInterp() /// /// Verifies data interp selection yank copies text and flashes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task DataInterp_SelectionYank_CopiesTextAndFlashes() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll, TabId.HexDump); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll, TabId.HexDump); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -1618,13 +1690,7 @@ public async Task DataInterp_SelectionYank_CopiesTextAndFlashes() .ApplyAsync(terminal, ct); // Yank - await new Hex1bTerminalInputSequenceBuilder() - .Type("y") - .WaitUntil(s => s.ContainsText("Yanked:"), TimeSpan.FromSeconds(5)) - .Build() - .ApplyAsync(terminal, ct); - - Assert.NotNull(_state!.YankNotification); + _ = await TypeYAndCaptureNotificationAsync(terminal, ct); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -1633,10 +1699,11 @@ public async Task DataInterp_SelectionYank_CopiesTextAndFlashes() /// /// Verifies data interp word selection and yanks. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task DataInterp_WordSelectionAndYanks() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll, TabId.HexDump); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll, TabId.HexDump); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -1656,13 +1723,7 @@ public async Task DataInterp_WordSelectionAndYanks() .ApplyAsync(terminal, ct); // Yank the selection - await new Hex1bTerminalInputSequenceBuilder() - .Type("y") - .WaitUntil(s => s.ContainsText("Yanked:"), TimeSpan.FromSeconds(5)) - .Build() - .ApplyAsync(terminal, ct); - - Assert.NotNull(_state!.YankNotification); + _ = await TypeYAndCaptureNotificationAsync(terminal, ct); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -1671,10 +1732,11 @@ public async Task DataInterp_WordSelectionAndYanks() /// /// Verifies hex dump insert mode only activates from hex editor. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task HexDump_InsertModeOnlyActivatesFromHexEditor() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll, TabId.HexDump); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll, TabId.HexDump); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -1697,7 +1759,7 @@ public async Task HexDump_InsertModeOnlyActivatesFromHexEditor() .WaitUntil(_ => true, TimeSpan.FromSeconds(1)) .Build() .ApplyAsync(terminal, ct); - Assert.Equal(HexEditMode.Normal, _state!.HexMode); + Assert.AreEqual(HexEditMode.Normal, _state!.HexMode); // Tab back to hex editor, then press 'i' — should enter insert mode. // Both steps in one sequence so the binding re-registration from the @@ -1709,7 +1771,7 @@ public async Task HexDump_InsertModeOnlyActivatesFromHexEditor() .WaitUntil(_ => _state!.HexMode == HexEditMode.Insert, TimeSpan.FromSeconds(5)) .Build() .ApplyAsync(terminal, ct); - Assert.Equal(HexEditMode.Insert, _state!.HexMode); + Assert.AreEqual(HexEditMode.Insert, _state!.HexMode); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } @@ -1718,10 +1780,11 @@ public async Task HexDump_InsertModeOnlyActivatesFromHexEditor() /// /// Verifies hex dump search refocuses to hex editor. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task HexDump_SearchRefocusesToHexEditor() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll, TabId.HexDump); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll, TabId.HexDump); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -1751,7 +1814,7 @@ public async Task HexDump_SearchRefocusesToHexEditor() .Build() .ApplyAsync(terminal, ct); - Assert.True(IsFocusedOnEditor(_state!.HexEditorState), + Assert.IsTrue(IsFocusedOnEditor(_state!.HexEditorState), "Search confirm should refocus to hex editor"); // Now test Escape dismiss: Tab to data interp, activate search, @@ -1770,7 +1833,7 @@ public async Task HexDump_SearchRefocusesToHexEditor() .Build() .ApplyAsync(terminal, ct); - Assert.True(IsFocusedOnEditor(_state!.HexEditorState), + Assert.IsTrue(IsFocusedOnEditor(_state!.HexEditorState), "Search dismiss should refocus to hex editor"); _cts!.Cancel(); @@ -1780,10 +1843,11 @@ public async Task HexDump_SearchRefocusesToHexEditor() /// /// Verifies hex dump data interp updates on cursor move and endian toggle. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task HexDump_DataInterpUpdatesOnCursorMoveAndEndianToggle() { - var (terminal, app, ct) = Launch(samples.RichLibraryDll, TabId.HexDump); + var (terminal, app, ct) = Launch(Samples.RichLibraryDll, TabId.HexDump); var runTask = app.RunAsync(ct); await new Hex1bTerminalInputSequenceBuilder() @@ -1801,7 +1865,7 @@ public async Task HexDump_DataInterpUpdatesOnCursorMoveAndEndianToggle() // Capture current data interp text var textBefore = _state!.DataInterpEditorText; - Assert.NotNull(textBefore); + Assert.IsNotNull(textBefore); // Move cursor right — values should change // Second byte of MZ header is 0x5A ('Z'), Int8 = 90 @@ -1812,7 +1876,7 @@ public async Task HexDump_DataInterpUpdatesOnCursorMoveAndEndianToggle() .ApplyAsync(terminal, ct); var textAfterMove = _state!.DataInterpEditorText; - Assert.NotEqual(textBefore, textAfterMove); + Assert.AreNotEqual(textBefore, textAfterMove); // Toggle endianness — multi-byte values should change var textBeforeEndian = _state!.DataInterpEditorText; @@ -1823,7 +1887,7 @@ public async Task HexDump_DataInterpUpdatesOnCursorMoveAndEndianToggle() .ApplyAsync(terminal, ct); var textAfterEndian = _state!.DataInterpEditorText; - Assert.NotEqual(textBeforeEndian, textAfterEndian); + Assert.AreNotEqual(textBeforeEndian, textAfterEndian); _cts!.Cancel(); try { await runTask; } catch (OperationCanceledException) { } diff --git a/tests/Dotsider.Tests/StringExtractorTests.cs b/tests/Dotsider.Tests/StringExtractorTests.cs index c0226316..30bbe848 100644 --- a/tests/Dotsider.Tests/StringExtractorTests.cs +++ b/tests/Dotsider.Tests/StringExtractorTests.cs @@ -7,200 +7,217 @@ namespace Dotsider.Tests; /// /// Tests for String Extractor. /// -[Collection("SampleAssemblies")] -public class StringExtractorTests(SampleAssemblyFixture samples) +[TestClass] +public class StringExtractorTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// Verifies hello world user strings contain output text. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void HelloWorld_UserStrings_ContainOutputText() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); var extractor = new StringExtractor(a); var strings = extractor.ExtractUserStrings(); - Assert.NotEmpty(strings); + Assert.IsNotEmpty(strings); // HelloWorld prints messages that should appear as user strings - Assert.Contains(strings, s => s.Source == StringSource.UserStrings); + Assert.Contains(s => s.Source == StringSource.UserStrings, strings); } /// /// Verifies rich library metadata strings contain type names. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RichLibrary_MetadataStrings_ContainTypeNames() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var extractor = new StringExtractor(a); var strings = extractor.ExtractMetadataStrings(); - Assert.NotEmpty(strings); - Assert.Contains(strings, s => s.Value.Contains("UserService")); + Assert.IsNotEmpty(strings); + Assert.Contains(s => s.Value.Contains("UserService"), strings); } /// /// Verifies complex app user strings contain pipeline text. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ComplexApp_UserStrings_ContainPipelineText() { - using var a = new AssemblyAnalyzer(samples.ComplexAppDll); + using var a = new AssemblyAnalyzer(Samples.ComplexAppDll); var extractor = new StringExtractor(a); var strings = extractor.ExtractUserStrings(); - Assert.NotEmpty(strings); + Assert.IsNotEmpty(strings); } /// /// Verifies minimal api user strings contain endpoint paths. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void MinimalApi_UserStrings_ContainEndpointPaths() { - using var a = new AssemblyAnalyzer(samples.MinimalApiDll); + using var a = new AssemblyAnalyzer(Samples.MinimalApiDll); var extractor = new StringExtractor(a); var strings = extractor.ExtractUserStrings(); - Assert.NotEmpty(strings); - Assert.Contains(strings, s => s.Value.Contains("/hello") || s.Value.Contains("/echo")); + Assert.IsNotEmpty(strings); + Assert.Contains(s => s.Value.Contains("/hello") || s.Value.Contains("/echo"), strings); } /// /// Verifies empty lib minimal strings. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void EmptyLib_MinimalStrings() { - using var a = new AssemblyAnalyzer(samples.EmptyLibDll); + using var a = new AssemblyAnalyzer(Samples.EmptyLibDll); var extractor = new StringExtractor(a); var userStrings = extractor.ExtractUserStrings(); // EmptyLib should have few or no user strings - Assert.True(userStrings.Count <= 5); + Assert.IsLessThanOrEqualTo(5, userStrings.Count); } /// /// Verifies raw strings min length4 more than min length16. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RawStrings_MinLength4_MoreThanMinLength16() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var extractor = new StringExtractor(a); var raw4 = extractor.ExtractRawStrings(4); var raw16 = extractor.ExtractRawStrings(16); - Assert.True(raw4.Count >= raw16.Count); + Assert.IsGreaterThanOrEqualTo(raw16.Count, raw4.Count); } /// /// Verifies raw strings default min length4. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RawStrings_Default_MinLength4() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); var extractor = new StringExtractor(a); var raw = extractor.ExtractRawStrings(); - Assert.NotEmpty(raw); - Assert.All(raw, s => Assert.True(s.Value.Length >= 4)); + Assert.IsNotEmpty(raw); + TestAssert.All(raw, s => Assert.IsGreaterThanOrEqualTo(4, s.Value.Length)); } /// /// Verifies metadata strings contain namespaces. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void MetadataStrings_ContainNamespaces() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var extractor = new StringExtractor(a); var strings = extractor.ExtractMetadataStrings(); - Assert.Contains(strings, s => s.Value.Contains("RichLibrary")); + Assert.Contains(s => s.Value.Contains("RichLibrary"), strings); } /// /// Verifies user strings all have correct source. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void UserStrings_AllHaveCorrectSource() { - using var a = new AssemblyAnalyzer(samples.ComplexAppDll); + using var a = new AssemblyAnalyzer(Samples.ComplexAppDll); var extractor = new StringExtractor(a); var strings = extractor.ExtractUserStrings(); - Assert.All(strings, s => Assert.Equal(StringSource.UserStrings, s.Source)); + TestAssert.All(strings, s => Assert.AreEqual(StringSource.UserStrings, s.Source)); } /// /// Verifies metadata strings all have correct source. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void MetadataStrings_AllHaveCorrectSource() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var extractor = new StringExtractor(a); var strings = extractor.ExtractMetadataStrings(); - Assert.All(strings, s => Assert.Equal(StringSource.MetadataStrings, s.Source)); + TestAssert.All(strings, s => Assert.AreEqual(StringSource.MetadataStrings, s.Source)); } /// /// Verifies raw strings all have correct source. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RawStrings_AllHaveCorrectSource() { - using var a = new AssemblyAnalyzer(samples.HelloWorldDll); + using var a = new AssemblyAnalyzer(Samples.HelloWorldDll); var extractor = new StringExtractor(a); var strings = extractor.ExtractRawStrings(); - Assert.All(strings, s => Assert.Equal(StringSource.RawBinary, s.Source)); + TestAssert.All(strings, s => Assert.AreEqual(StringSource.RawBinary, s.Source)); } /// /// Verifies native lib has metadata strings. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeLib_HasMetadataStrings() { - using var a = new AssemblyAnalyzer(samples.NativeLibDll); + using var a = new AssemblyAnalyzer(Samples.NativeLibDll); var extractor = new StringExtractor(a); var strings = extractor.ExtractMetadataStrings(); - Assert.NotEmpty(strings); - Assert.Contains(strings, s => s.Value.Contains("NativeInterop") || s.Value.Contains("UnsafeOperations")); + Assert.IsNotEmpty(strings); + Assert.Contains(s => s.Value.Contains("NativeInterop") || s.Value.Contains("UnsafeOperations"), strings); } /// /// Verifies raw strings min length8 all meet minimum. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RawStrings_MinLength8_AllMeetMinimum() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var extractor = new StringExtractor(a); var raw = extractor.ExtractRawStrings(8); - Assert.NotEmpty(raw); - Assert.All(raw, s => Assert.True(s.Value.Length >= 8)); + Assert.IsNotEmpty(raw); + TestAssert.All(raw, s => Assert.IsGreaterThanOrEqualTo(8, s.Value.Length)); } /// /// Verifies string entries have positive offsets. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void StringEntries_HavePositiveOffsets() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var extractor = new StringExtractor(a); var userStrings = extractor.ExtractUserStrings(); - Assert.All(userStrings, s => Assert.True(s.Offset >= 0)); + TestAssert.All(userStrings, s => Assert.IsGreaterThanOrEqualTo(0, s.Offset)); var metaStrings = extractor.ExtractMetadataStrings(); - Assert.All(metaStrings, s => Assert.True(s.Offset >= 0)); + TestAssert.All(metaStrings, s => Assert.IsGreaterThanOrEqualTo(0, s.Offset)); } /// /// Verifies skipped counts zero for valid assembly. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void SkippedCounts_ZeroForValidAssembly() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var extractor = new StringExtractor(a); extractor.ExtractUserStrings(); - Assert.Equal(0, extractor.SkippedUserStringCount); + Assert.AreEqual(0, extractor.SkippedUserStringCount); extractor.ExtractMetadataStrings(); - Assert.Equal(0, extractor.SkippedMetadataStringCount); + Assert.AreEqual(0, extractor.SkippedMetadataStringCount); } /// @@ -208,12 +225,13 @@ public void SkippedCounts_ZeroForValidAssembly() /// crashes with BadImageFormatException because GetNextHandle reads past the /// end of the #US heap. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RuntimeAssembly_ExtractUserStrings_DoesNotThrow() { var runtimeDir = RuntimeEnvironment.GetRuntimeDirectory(); var systemRuntime = Path.Combine(runtimeDir, "System.Runtime.dll"); - Assert.True(File.Exists(systemRuntime), $"System.Runtime.dll not found at {runtimeDir}"); + Assert.IsTrue(File.Exists(systemRuntime), $"System.Runtime.dll not found at {runtimeDir}"); using var a = new AssemblyAnalyzer(systemRuntime); var extractor = new StringExtractor(a); @@ -222,83 +240,88 @@ public void RuntimeAssembly_ExtractUserStrings_DoesNotThrow() // System.Runtime has no user string literals (its #US heap is zero bytes). // Must not crash, and must not report false skips. - Assert.NotNull(strings); - Assert.Empty(strings); - Assert.Equal(0, extractor.SkippedUserStringCount); + Assert.IsNotNull(strings); + Assert.IsEmpty(strings); + Assert.AreEqual(0, extractor.SkippedUserStringCount); } /// /// Same as above but for the #Strings metadata heap, which has the same /// unguarded GetNextHandle pattern. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void RuntimeAssembly_ExtractMetadataStrings_DoesNotThrow() { var runtimeDir = RuntimeEnvironment.GetRuntimeDirectory(); var systemRuntime = Path.Combine(runtimeDir, "System.Runtime.dll"); - Assert.True(File.Exists(systemRuntime), $"System.Runtime.dll not found at {runtimeDir}"); + Assert.IsTrue(File.Exists(systemRuntime), $"System.Runtime.dll not found at {runtimeDir}"); using var a = new AssemblyAnalyzer(systemRuntime); var extractor = new StringExtractor(a); var strings = extractor.ExtractMetadataStrings(); - Assert.NotNull(strings); + Assert.IsNotNull(strings); } /// /// Verifies the UTF-16 raw scan finds frozen managed string literals in a /// Native AOT binary (the DOTNET_ environment variable names the runtime reads). /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void NativeAot_ExtractRawUtf16Strings_FindsFrozenLiterals() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - using var a = new AssemblyAnalyzer(samples.NativeAotConsoleExe!); + using var a = new AssemblyAnalyzer(Samples.NativeAotConsoleExe!); var extractor = new StringExtractor(a); var strings = extractor.ExtractRawUtf16Strings(minLength: 8); - Assert.NotEmpty(strings); - Assert.Contains(strings, s => s.Value.Contains("DOTNET_", StringComparison.Ordinal)); - Assert.All(strings, s => Assert.Equal(StringSource.RawBinaryUtf16, s.Source)); + Assert.IsNotEmpty(strings); + Assert.Contains(s => s.Value.Contains("DOTNET_", StringComparison.Ordinal), strings); + TestAssert.All(strings, s => Assert.AreEqual(StringSource.RawBinaryUtf16, s.Source)); } /// /// Verifies the UTF-16 raw scan honors the minimum length. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ExtractRawUtf16Strings_RespectsMinLength() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var extractor = new StringExtractor(a); var strings = extractor.ExtractRawUtf16Strings(minLength: 16); - Assert.All(strings, s => Assert.True(s.Value.Length >= 16)); + TestAssert.All(strings, s => Assert.IsGreaterThanOrEqualTo(16, s.Value.Length)); } /// /// Verifies UTF-16 entries carry offsets inside the file. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ExtractRawUtf16Strings_HaveValidOffsets() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var extractor = new StringExtractor(a); var strings = extractor.ExtractRawUtf16Strings(); - Assert.All(strings, s => Assert.InRange(s.Offset, 0, (int)a.FileSize - 1)); + TestAssert.All(strings, s => Assert.IsInRange(0, (int)a.FileSize - 1, s.Offset)); } /// /// Verifies a UTF-16 run at an odd byte offset is found — UTF-16 data sits at /// arbitrary alignments in real binaries. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void ExtractRawUtf16Strings_OddOffsetRun_Found() { var bytes = new byte[64]; @@ -317,7 +340,7 @@ public void ExtractRawUtf16Strings_OddOffsetRun_Found() var strings = extractor.ExtractRawUtf16Strings(minLength: 5); - var entry = Assert.Single(strings, s => s.Value == "Hello"); - Assert.Equal(7, entry.Offset); + var entry = Assert.ContainsSingle(s => s.Value == "Hello", strings); + Assert.AreEqual(7, entry.Offset); } } diff --git a/tests/Dotsider.Tests/StringsViewTests.cs b/tests/Dotsider.Tests/StringsViewTests.cs index 16e36cbb..4348a7dd 100644 --- a/tests/Dotsider.Tests/StringsViewTests.cs +++ b/tests/Dotsider.Tests/StringsViewTests.cs @@ -8,9 +8,11 @@ namespace Dotsider.Tests; /// /// Tests for Strings View. /// -[Collection("SampleAssemblies")] -public class StringsViewTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class StringsViewTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _hex1bApp; @@ -28,7 +30,7 @@ public class StringsViewTests(SampleAssemblyFixture samples) : IDisposable _hex1bApp = new Hex1bApp( ctx => { - _state ??= new DotsiderState(_hex1bApp!, assemblyPath ?? samples.RichLibraryDll) + _state ??= new DotsiderState(_hex1bApp!, assemblyPath ?? Samples.RichLibraryDll) { CurrentTab = TabId.Strings }; @@ -46,11 +48,12 @@ public class StringsViewTests(SampleAssemblyFixture samples) : IDisposable /// /// Verifies strings enter opens detail popup. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Strings_EnterOpensDetailPopup() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -64,7 +67,7 @@ public async Task Strings_EnterOpensDetailPopup() .Build() .ApplyAsync(terminal, cts.Token); - Assert.NotNull(_state!.StringsDetailContent); + Assert.IsNotNull(_state!.StringsDetailContent); cts.Cancel(); await runTask; @@ -73,11 +76,12 @@ public async Task Strings_EnterOpensDetailPopup() /// /// Verifies strings escape closes detail popup. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Strings_EscapeClosesDetailPopup() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -94,7 +98,7 @@ public async Task Strings_EscapeClosesDetailPopup() .Build() .ApplyAsync(terminal, cts.Token); - Assert.Null(_state!.StringsDetailContent); + Assert.IsNull(_state!.StringsDetailContent); cts.Cancel(); await runTask; @@ -103,11 +107,12 @@ public async Task Strings_EscapeClosesDetailPopup() /// /// Verifies strings detail popup shows length. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Strings_DetailPopupShowsLength() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -121,7 +126,7 @@ public async Task Strings_DetailPopupShowsLength() .Build() .ApplyAsync(terminal, cts.Token); - Assert.NotNull(_state!.StringsDetailContent); + Assert.IsNotNull(_state!.StringsDetailContent); cts.Cancel(); await runTask; @@ -130,11 +135,12 @@ public async Task Strings_DetailPopupShowsLength() /// /// Verifies strings arrow and enter work after detail dismissed. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Strings_ArrowAndEnterWorkAfterDetailDismissed() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -153,7 +159,7 @@ public async Task Strings_ArrowAndEnterWorkAfterDetailDismissed() .Build() .ApplyAsync(terminal, cts.Token); - Assert.NotNull(_state!.StringsDetailContent); + Assert.IsNotNull(_state!.StringsDetailContent); cts.Cancel(); await runTask; @@ -162,11 +168,12 @@ public async Task Strings_ArrowAndEnterWorkAfterDetailDismissed() /// /// Verifies strings enter works after search dismissed. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Strings_EnterWorksAfterSearchDismissed() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -184,7 +191,7 @@ public async Task Strings_EnterWorksAfterSearchDismissed() .Build() .ApplyAsync(terminal, cts.Token); - Assert.NotNull(_state!.StringsDetailContent); + Assert.IsNotNull(_state!.StringsDetailContent); cts.Cancel(); await runTask; @@ -193,7 +200,8 @@ public async Task Strings_EnterWorksAfterSearchDismissed() /// /// Verifies strings detail popup shows content. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Strings_DetailPopupShowsContent() { // Use a larger terminal so the popup has room to render content @@ -207,7 +215,7 @@ public async Task Strings_DetailPopupShowsContent() _hex1bApp = new Hex1bApp( ctx => { - _state ??= new DotsiderState(_hex1bApp!, samples.RichLibraryDll) + _state ??= new DotsiderState(_hex1bApp!, Samples.RichLibraryDll) { CurrentTab = TabId.Strings }; @@ -221,7 +229,7 @@ public async Task Strings_DetailPopupShowsContent() }); var terminal = _terminal; var app = _hex1bApp; - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -238,7 +246,7 @@ public async Task Strings_DetailPopupShowsContent() .Build() .ApplyAsync(terminal, cts.Token); - Assert.NotNull(_state!.StringsDetailContent); + Assert.IsNotNull(_state!.StringsDetailContent); Assert.Contains("Length:", _state.StringsDetailContent is not null ? $"Length: {_state.StringsDetailContent.Length}" : ""); @@ -249,11 +257,12 @@ public async Task Strings_DetailPopupShowsContent() /// /// Verifies strings escape during search does not crash. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Strings_EscapeDuringSearchDoesNotCrash() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -269,7 +278,7 @@ public async Task Strings_EscapeDuringSearchDoesNotCrash() .Build() .ApplyAsync(terminal, cts.Token); - Assert.False(_state!.Search[TabId.Strings].IsActive); + Assert.IsFalse(_state!.Search[TabId.Strings].IsActive); cts.Cancel(); await runTask; @@ -278,11 +287,12 @@ public async Task Strings_EscapeDuringSearchDoesNotCrash() /// /// Verifies strings detail popup shows string content. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Strings_DetailPopupShowsStringContent() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -297,8 +307,8 @@ public async Task Strings_DetailPopupShowsStringContent() .ApplyAsync(terminal, cts.Token); // The detail popup should show both the Length label and the string value - Assert.NotNull(_state!.StringsDetailContent); - Assert.True(_state.StringsDetailContent.Length > 0); + Assert.IsNotNull(_state!.StringsDetailContent); + Assert.IsGreaterThan(0, _state.StringsDetailContent.Length); cts.Cancel(); await runTask; @@ -307,11 +317,12 @@ public async Task Strings_DetailPopupShowsStringContent() /// /// Verifies the fourth sub-tab (Raw UTF-16) renders and becomes active. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Strings_NavigateToRawUtf16_Renders() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -331,7 +342,7 @@ await builder .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(StringsSubTabId.RawBinaryUtf16, _state!.StringsSourceTab); + Assert.AreEqual(StringsSubTabId.RawBinaryUtf16, _state!.StringsSourceTab); cts.Cancel(); await runTask; @@ -341,11 +352,12 @@ await builder /// Verifies changing min length on the Raw UTF-16 sub-tab invalidates and /// rebuilds its cache with the new minimum. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Strings_MinLengthChange_InvalidatesUtf16Cache() { var (terminal, app) = CreateDotsiderApp(); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -370,8 +382,8 @@ await builder .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(5, _state!.StringsMinLength); - Assert.NotNull(_state.CachedRawUtf16Strings); + Assert.AreEqual(5, _state!.StringsMinLength); + Assert.IsNotNull(_state.CachedRawUtf16Strings); cts.Cancel(); await runTask; @@ -382,13 +394,14 @@ await builder /// binary. The frozen strings are file-backed on Windows and macOS; on Linux the region /// is filled at startup so the sub-tab is present but empty. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Strings_NativeAot_FrozenSubTab_Renders() { - Assert.SkipWhen(samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); + TestSkip.When(Samples.NativeAotConsoleExe is null, "NativeAOT sample was not built"); - var (terminal, app) = CreateDotsiderApp(samples.NativeAotConsoleExe); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + var (terminal, app) = CreateDotsiderApp(Samples.NativeAotConsoleExe); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); await Task.Delay(100, cts.Token); @@ -408,7 +421,7 @@ await builder .Build() .ApplyAsync(terminal, cts.Token); - Assert.Equal(StringsSubTabId.FrozenObject, _state!.StringsSourceTab); + Assert.AreEqual(StringsSubTabId.FrozenObject, _state!.StringsSourceTab); cts.Cancel(); await runTask; @@ -419,10 +432,10 @@ await builder /// public void Dispose() { + GC.SuppressFinalize(this); _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/TablePopupLayoutTests.cs b/tests/Dotsider.Tests/TablePopupLayoutTests.cs index 6eea2676..313cf703 100644 --- a/tests/Dotsider.Tests/TablePopupLayoutTests.cs +++ b/tests/Dotsider.Tests/TablePopupLayoutTests.cs @@ -9,9 +9,11 @@ namespace Dotsider.Tests; /// Reproduces #88: opening a detail popup in the Strings or PE/Metadata tab causes /// the underlying table to shrink its bottom border to just below the last data row. /// -[Collection("SampleAssemblies")] -public class TablePopupLayoutTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class TablePopupLayoutTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _hex1bApp; @@ -29,7 +31,7 @@ public class TablePopupLayoutTests(SampleAssemblyFixture samples) : IDisposable _hex1bApp = new Hex1bApp( ctx => { - _state ??= new DotsiderState(_hex1bApp!, samples.RichLibraryDll) + _state ??= new DotsiderState(_hex1bApp!, Samples.RichLibraryDll) { CurrentTab = startTab }; @@ -47,11 +49,12 @@ public class TablePopupLayoutTests(SampleAssemblyFixture samples) : IDisposable /// /// Verifies strings table bottom border does not move when popup opens. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task Strings_TableBottomBorderDoesNotMoveWhenPopupOpens() { var (terminal, app) = CreateDotsiderApp(TabId.Strings); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); int bottomBorderRowBefore = -1; @@ -90,8 +93,8 @@ public async Task Strings_TableBottomBorderDoesNotMoveWhenPopupOpens() .Build() .ApplyAsync(terminal, cts.Token); - Assert.True(bottomBorderRowBefore > 0, "Could not find table bottom border before popup"); - Assert.Equal(bottomBorderRowBefore, bottomBorderRowDuring); + Assert.IsGreaterThan(0, bottomBorderRowBefore, "Could not find table bottom border before popup"); + Assert.AreEqual(bottomBorderRowBefore, bottomBorderRowDuring); cts.Cancel(); await runTask; @@ -100,11 +103,12 @@ public async Task Strings_TableBottomBorderDoesNotMoveWhenPopupOpens() /// /// Verifies pe metadata table bottom border does not move when popup opens. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_TableBottomBorderDoesNotMoveWhenPopupOpens() { var (terminal, app) = CreateDotsiderApp(TabId.PeMetadata); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); int bottomBorderRowBefore = -1; @@ -142,8 +146,8 @@ public async Task PeMetadata_TableBottomBorderDoesNotMoveWhenPopupOpens() .Build() .ApplyAsync(terminal, cts.Token); - Assert.True(bottomBorderRowBefore > 0, "Could not find table bottom border before popup"); - Assert.Equal(bottomBorderRowBefore, bottomBorderRowDuring); + Assert.IsGreaterThan(0, bottomBorderRowBefore, "Could not find table bottom border before popup"); + Assert.AreEqual(bottomBorderRowBefore, bottomBorderRowDuring); cts.Cancel(); await runTask; @@ -154,10 +158,10 @@ public async Task PeMetadata_TableBottomBorderDoesNotMoveWhenPopupOpens() /// public void Dispose() { + GC.SuppressFinalize(this); _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/TableScrollPopupTests.cs b/tests/Dotsider.Tests/TableScrollPopupTests.cs index 30f43d55..7abfb922 100644 --- a/tests/Dotsider.Tests/TableScrollPopupTests.cs +++ b/tests/Dotsider.Tests/TableScrollPopupTests.cs @@ -9,9 +9,11 @@ namespace Dotsider.Tests; /// Reproduces #90: opening a detail popup in PE/Metadata resets the table's /// scroll position to the top. The viewport content should stay put. /// -[Collection("SampleAssemblies")] -public class TableScrollPopupTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class TableScrollPopupTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Hex1bAppWorkloadAdapter? _workload; private Hex1bTerminal? _terminal; private Hex1bApp? _hex1bApp; @@ -29,7 +31,7 @@ public class TableScrollPopupTests(SampleAssemblyFixture samples) : IDisposable _hex1bApp = new Hex1bApp( ctx => { - _state ??= new DotsiderState(_hex1bApp!, samples.RichLibraryDll) + _state ??= new DotsiderState(_hex1bApp!, Samples.RichLibraryDll) { CurrentTab = startTab, PeSubTab = subTab @@ -48,12 +50,13 @@ public class TableScrollPopupTests(SampleAssemblyFixture samples) : IDisposable /// /// Verifies pe metadata scroll position preserved when popup opens. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task PeMetadata_ScrollPositionPreservedWhenPopupOpens() { // MethodDef sub-tab: RichLibrary has many methods, enough to scroll var (terminal, app) = CreateDotsiderApp(TabId.PeMetadata, PeSubTabId.MethodDef); - using var cts = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken); + using var cts = CancellationTokenSource.CreateLinkedTokenSource(CancellationToken.None); var runTask = app.RunAsync(cts.Token); // We use the second row's token as a scroll marker. When we scroll @@ -96,7 +99,7 @@ public async Task PeMetadata_ScrollPositionPreservedWhenPopupOpens() // If scroll reset to top, the second row token would reappear var snapshot = terminal.CreateSnapshot(); - Assert.False(snapshot.ContainsText(secondRowToken!), + Assert.IsFalse(snapshot.ContainsText(secondRowToken!), $"Table scrolled back to top when popup opened — row {secondRowToken} reappeared"); cts.Cancel(); @@ -108,10 +111,10 @@ public async Task PeMetadata_ScrollPositionPreservedWhenPopupOpens() /// public void Dispose() { + GC.SuppressFinalize(this); _state?.Dispose(); _hex1bApp?.Dispose(); _terminal?.Dispose(); _workload?.Dispose(); - GC.SuppressFinalize(this); } } diff --git a/tests/Dotsider.Tests/TestHelpers.cs b/tests/Dotsider.Tests/TestHelpers.cs index ffdd1012..bcb89d8a 100644 --- a/tests/Dotsider.Tests/TestHelpers.cs +++ b/tests/Dotsider.Tests/TestHelpers.cs @@ -17,7 +17,7 @@ internal static string ExpectedOsc52(string text) } /// - /// Logs a diagnostic message with timestamp to stderr (captured by xUnit). + /// Logs a diagnostic message with timestamp to stderr (captured by MSTest). /// internal static void Diag(string message, [CallerMemberName] string? caller = null) => Console.Error.WriteLine($"[DIAG {DateTime.UtcNow:HH:mm:ss.fff}] [{caller}] {message}"); @@ -37,7 +37,7 @@ internal static async Task WaitUntilAsync( while (sw.Elapsed < timeout) { if (condition()) return; - await Task.Delay(poll, TestContext.Current.CancellationToken); + await Task.Delay(poll, CancellationToken.None); } if (!condition()) @@ -93,6 +93,7 @@ private static string DetectBuildConfig() RedirectStandardOutput = true, RedirectStandardError = true, }; + TestProcessEnvironment.RemoveCodeCoverageVariables(psi); var process = Process.Start(psi)!; var stdout = await process.StandardOutput.ReadToEndAsync(); diff --git a/tests/Dotsider.Tests/TestProcessEnvironmentTests.cs b/tests/Dotsider.Tests/TestProcessEnvironmentTests.cs new file mode 100644 index 00000000..46125fb4 --- /dev/null +++ b/tests/Dotsider.Tests/TestProcessEnvironmentTests.cs @@ -0,0 +1,31 @@ +using System.Diagnostics; + +namespace Dotsider.Tests; + +/// +/// Tests for child-process environment sanitization used by integration tests. +/// +[TestClass] +public class TestProcessEnvironmentTests +{ + /// + /// Code-coverage profiler variables are removed without disturbing unrelated environment variables. + /// + [TestMethod] + public void RemoveCodeCoverageVariables_StripsProfilerEnvironment() + { + var startInfo = new ProcessStartInfo("dotnet") + { + UseShellExecute = false, + }; + startInfo.Environment["CORECLR_ENABLE_PROFILING"] = "1"; + startInfo.Environment["CORECLR_PROFILER"] = "{00000000-0000-0000-0000-000000000000}"; + startInfo.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"] = "1"; + + TestProcessEnvironment.RemoveCodeCoverageVariables(startInfo); + + Assert.IsFalse(startInfo.Environment.ContainsKey("CORECLR_ENABLE_PROFILING")); + Assert.IsFalse(startInfo.Environment.ContainsKey("CORECLR_PROFILER")); + Assert.AreEqual("1", startInfo.Environment["DOTNET_CLI_TELEMETRY_OPTOUT"]); + } +} diff --git a/tests/Dotsider.Tests/TestThreadPoolSetup.cs b/tests/Dotsider.Tests/TestThreadPoolSetup.cs index 72d3d5b2..47480162 100644 --- a/tests/Dotsider.Tests/TestThreadPoolSetup.cs +++ b/tests/Dotsider.Tests/TestThreadPoolSetup.cs @@ -11,9 +11,16 @@ namespace Dotsider.Tests; /// internal static class TestThreadPoolSetup { + private const int MinimumThreads = 128; + private const int ThreadsPerProcessor = 16; + [ModuleInitializer] internal static void Initialize() { - ThreadPool.SetMinThreads(32, 32); + var requiredThreads = Math.Max(MinimumThreads, Environment.ProcessorCount * ThreadsPerProcessor); + ThreadPool.GetMinThreads(out var workerThreads, out var completionPortThreads); + ThreadPool.SetMinThreads( + Math.Max(workerThreads, requiredThreads), + Math.Max(completionPortThreads, requiredThreads)); } } diff --git a/tests/Dotsider.Tests/TextObjectHelperTests.cs b/tests/Dotsider.Tests/TextObjectHelperTests.cs index fee5281d..f1772e4b 100644 --- a/tests/Dotsider.Tests/TextObjectHelperTests.cs +++ b/tests/Dotsider.Tests/TextObjectHelperTests.cs @@ -7,6 +7,7 @@ namespace Dotsider.Tests; /// /// Tests for Text Object Helper. /// +[TestClass] public class TextObjectHelperTests { private static EditorState CreateEditor(string text, int cursorOffset = 0) @@ -33,124 +34,124 @@ private static EditorState CreateEditor(string text, int cursorOffset = 0) /// /// Verifies select inner word on word chars selects contiguous run. /// - [Fact] + [TestMethod] public void SelectInnerWord_OnWordChars_SelectsContiguousRun() { var state = CreateEditor("hello_world foo", cursorOffset: 3); // cursor on 'l' TextObjectHelper.SelectInnerWord(state); - Assert.Equal("hello_world", GetSelectedText(state)); + Assert.AreEqual("hello_world", GetSelectedText(state)); } /// /// Verifies select inner word includes underscores. /// - [Fact] + [TestMethod] public void SelectInnerWord_IncludesUnderscores() { var state = CreateEditor("foo_bar", cursorOffset: 3); // cursor on '_' TextObjectHelper.SelectInnerWord(state); - Assert.Equal("foo_bar", GetSelectedText(state)); + Assert.AreEqual("foo_bar", GetSelectedText(state)); } /// /// Verifies select inner word on punctuation selects punctuation run. /// - [Fact] + [TestMethod] public void SelectInnerWord_OnPunctuation_SelectsPunctuationRun() { var state = CreateEditor("foo::bar", cursorOffset: 3); // cursor on first ':' TextObjectHelper.SelectInnerWord(state); - Assert.Equal("::", GetSelectedText(state)); + Assert.AreEqual("::", GetSelectedText(state)); } /// /// Verifies select inner word on whitespace selects whitespace run. /// - [Fact] + [TestMethod] public void SelectInnerWord_OnWhitespace_SelectsWhitespaceRun() { var state = CreateEditor("a b", cursorOffset: 2); // cursor on middle space TextObjectHelper.SelectInnerWord(state); - Assert.Equal(" ", GetSelectedText(state)); + Assert.AreEqual(" ", GetSelectedText(state)); } /// /// Verifies select inner word on tab selects tab run. /// - [Fact] + [TestMethod] public void SelectInnerWord_OnTab_SelectsTabRun() { var state = CreateEditor("a\t\tb", cursorOffset: 1); // cursor on first tab TextObjectHelper.SelectInnerWord(state); - Assert.Equal("\t\t", GetSelectedText(state)); + Assert.AreEqual("\t\t", GetSelectedText(state)); } /// /// Verifies select inner word does not cross newline. /// - [Fact] + [TestMethod] public void SelectInnerWord_DoesNotCrossNewline() { var state = CreateEditor("abc\ndef", cursorOffset: 2); // cursor on 'c' TextObjectHelper.SelectInnerWord(state); - Assert.Equal("abc", GetSelectedText(state)); + Assert.AreEqual("abc", GetSelectedText(state)); } /// /// Verifies select inner word on newline no selection. /// - [Fact] + [TestMethod] public void SelectInnerWord_OnNewline_NoSelection() { // Newlines are word boundaries and single-char tokens. // iw on a newline is a no-op — cursor stays put, no selection. var state = CreateEditor("abc\ndef", cursorOffset: 3); // cursor on '\n' TextObjectHelper.SelectInnerWord(state); - Assert.Null(GetSelectedText(state)); + Assert.IsNull(GetSelectedText(state)); } /// /// Verifies select inner word at document start selects from start. /// - [Fact] + [TestMethod] public void SelectInnerWord_AtDocumentStart_SelectsFromStart() { var state = CreateEditor("hello world", cursorOffset: 0); TextObjectHelper.SelectInnerWord(state); - Assert.Equal("hello", GetSelectedText(state)); + Assert.AreEqual("hello", GetSelectedText(state)); } /// /// Verifies select inner word at document end selects to end. /// - [Fact] + [TestMethod] public void SelectInnerWord_AtDocumentEnd_SelectsToEnd() { var state = CreateEditor("hello world", cursorOffset: 10); // cursor on 'd' TextObjectHelper.SelectInnerWord(state); - Assert.Equal("world", GetSelectedText(state)); + Assert.AreEqual("world", GetSelectedText(state)); } /// /// Verifies select inner word empty document no selection. /// - [Fact] + [TestMethod] public void SelectInnerWord_EmptyDocument_NoSelection() { var state = CreateEditor(""); TextObjectHelper.SelectInnerWord(state); - Assert.Null(GetSelectedText(state)); + Assert.IsNull(GetSelectedText(state)); } /// /// Verifies select inner word cursor at document length no selection. /// - [Fact] + [TestMethod] public void SelectInnerWord_CursorAtDocumentLength_NoSelection() { var state = CreateEditor("abc", cursorOffset: 3); TextObjectHelper.SelectInnerWord(state); - Assert.Null(GetSelectedText(state)); + Assert.IsNull(GetSelectedText(state)); } // --- SelectInnerWORD --- @@ -158,66 +159,66 @@ public void SelectInnerWord_CursorAtDocumentLength_NoSelection() /// /// Verifies select inner word on non whitespace selects to whitespace. /// - [Fact] + [TestMethod] public void SelectInnerWORD_OnNonWhitespace_SelectsToWhitespace() { var state = CreateEditor("foo::bar baz", cursorOffset: 4); // cursor on ':' TextObjectHelper.SelectInnerWORD(state); - Assert.Equal("foo::bar", GetSelectedText(state)); + Assert.AreEqual("foo::bar", GetSelectedText(state)); } /// /// Verifies select inner word qualified name selects entire fqn. /// - [Fact] + [TestMethod] public void SelectInnerWORD_QualifiedName_SelectsEntireFQN() { var state = CreateEditor("System.Runtime.CompilerServices.NullableAttribute", cursorOffset: 10); TextObjectHelper.SelectInnerWORD(state); - Assert.Equal("System.Runtime.CompilerServices.NullableAttribute", GetSelectedText(state)); + Assert.AreEqual("System.Runtime.CompilerServices.NullableAttribute", GetSelectedText(state)); } /// /// Verifies select inner word on whitespace selects whitespace run. /// - [Fact] + [TestMethod] public void SelectInnerWORD_OnWhitespace_SelectsWhitespaceRun() { var state = CreateEditor("a b", cursorOffset: 2); TextObjectHelper.SelectInnerWORD(state); - Assert.Equal(" ", GetSelectedText(state)); + Assert.AreEqual(" ", GetSelectedText(state)); } /// /// Verifies select inner word does not cross newline. /// - [Fact] + [TestMethod] public void SelectInnerWORD_DoesNotCrossNewline() { var state = CreateEditor("foo::bar\nbaz", cursorOffset: 4); TextObjectHelper.SelectInnerWORD(state); - Assert.Equal("foo::bar", GetSelectedText(state)); + Assert.AreEqual("foo::bar", GetSelectedText(state)); } /// /// Verifies select inner word single token selects all. /// - [Fact] + [TestMethod] public void SelectInnerWORD_SingleToken_SelectsAll() { var state = CreateEditor("abc", cursorOffset: 1); TextObjectHelper.SelectInnerWORD(state); - Assert.Equal("abc", GetSelectedText(state)); + Assert.AreEqual("abc", GetSelectedText(state)); } /// /// Verifies select inner word empty document no selection. /// - [Fact] + [TestMethod] public void SelectInnerWORD_EmptyDocument_NoSelection() { var state = CreateEditor(""); TextObjectHelper.SelectInnerWORD(state); - Assert.Null(GetSelectedText(state)); + Assert.IsNull(GetSelectedText(state)); } } diff --git a/tests/Dotsider.Tests/TreemapLayoutTests.cs b/tests/Dotsider.Tests/TreemapLayoutTests.cs index b8af3d25..b2976640 100644 --- a/tests/Dotsider.Tests/TreemapLayoutTests.cs +++ b/tests/Dotsider.Tests/TreemapLayoutTests.cs @@ -7,46 +7,51 @@ namespace Dotsider.Tests; /// /// Tests for Treemap Layout. /// -[Collection("SampleAssemblies")] -public class TreemapLayoutTests(SampleAssemblyFixture samples) +[TestClass] +public class TreemapLayoutTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// Verifies layout produces rects within bounds. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Layout_ProducesRectsWithinBounds() { var nodes = CreateTestNodes(5); var rects = TreemapLayout.Layout(nodes, 0, 0, 100, 100); - Assert.NotEmpty(rects); + Assert.IsNotEmpty(rects); foreach (var rect in rects) { - Assert.True(rect.X >= -0.01); - Assert.True(rect.Y >= -0.01); - Assert.True(rect.X + rect.Width <= 100.01); - Assert.True(rect.Y + rect.Height <= 100.01); + Assert.IsGreaterThanOrEqualTo(-0.01, rect.X); + Assert.IsGreaterThanOrEqualTo(-0.01, rect.Y); + Assert.IsLessThanOrEqualTo(100.01, rect.X + rect.Width); + Assert.IsLessThanOrEqualTo(100.01, rect.Y + rect.Height); } } /// /// Verifies layout no negative dimensions. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Layout_NoNegativeDimensions() { var nodes = CreateTestNodes(10); var rects = TreemapLayout.Layout(nodes, 0, 0, 200, 100); - Assert.All(rects, r => + TestAssert.All(rects, r => { - Assert.True(r.Width >= 0); - Assert.True(r.Height >= 0); + Assert.IsGreaterThanOrEqualTo(0, r.Width); + Assert.IsGreaterThanOrEqualTo(0, r.Height); }); } /// /// Verifies layout no overlapping rects. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Layout_NoOverlappingRects() { var nodes = CreateTestNodes(8); @@ -57,7 +62,8 @@ public void Layout_NoOverlappingRects() /// /// Verifies layout single node fills entire space. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Layout_SingleNode_FillsEntireSpace() { var nodes = new List @@ -65,86 +71,92 @@ public void Layout_SingleNode_FillsEntireSpace() new("single", "single", 100, SizeNodeKind.Type, []) }; var rects = TreemapLayout.Layout(nodes, 0, 0, 50, 30); - Assert.Single(rects); - Assert.Equal(0, rects[0].X, 0.01); - Assert.Equal(0, rects[0].Y, 0.01); - Assert.Equal(50, rects[0].Width, 0.01); - Assert.Equal(30, rects[0].Height, 0.01); + Assert.ContainsSingle(rects); + Assert.AreEqual(0, rects[0].X, 0.01); + Assert.AreEqual(0, rects[0].Y, 0.01); + Assert.AreEqual(50, rects[0].Width, 0.01); + Assert.AreEqual(30, rects[0].Height, 0.01); } /// /// Verifies layout empty input returns empty. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Layout_EmptyInput_ReturnsEmpty() { var rects = TreemapLayout.Layout([], 0, 0, 100, 100); - Assert.Empty(rects); + Assert.IsEmpty(rects); } /// /// Verifies layout zero width or height returns empty. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Layout_ZeroWidthOrHeight_ReturnsEmpty() { var nodes = CreateTestNodes(3); - Assert.Empty(TreemapLayout.Layout(nodes, 0, 0, 0, 100)); - Assert.Empty(TreemapLayout.Layout(nodes, 0, 0, 100, 0)); + Assert.IsEmpty(TreemapLayout.Layout(nodes, 0, 0, 0, 100)); + Assert.IsEmpty(TreemapLayout.Layout(nodes, 0, 0, 100, 0)); } /// /// Verifies layout real size tree produces valid rects. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Layout_RealSizeTree_ProducesValidRects() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var tree = SizeAnalyzer.BuildSizeTree(a); var rects = TreemapLayout.Layout(tree.Children, 0, 0, 800, 600); - Assert.NotEmpty(rects); - Assert.All(rects, r => + Assert.IsNotEmpty(rects); + TestAssert.All(rects, r => { - Assert.True(r.Width >= 0); - Assert.True(r.Height >= 0); + Assert.IsGreaterThanOrEqualTo(0, r.Width); + Assert.IsGreaterThanOrEqualTo(0, r.Height); }); } /// /// Verifies layout many small nodes all fit within bounds. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Layout_ManySmallNodes_AllFitWithinBounds() { var nodes = CreateTestNodes(20); var rects = TreemapLayout.Layout(nodes, 10, 10, 200, 150); - Assert.NotEmpty(rects); + Assert.IsNotEmpty(rects); foreach (var rect in rects) { - Assert.True(rect.X >= 9.99); - Assert.True(rect.Y >= 9.99); - Assert.True(rect.X + rect.Width <= 210.01); - Assert.True(rect.Y + rect.Height <= 160.01); + Assert.IsGreaterThanOrEqualTo(9.99, rect.X); + Assert.IsGreaterThanOrEqualTo(9.99, rect.Y); + Assert.IsLessThanOrEqualTo(210.01, rect.X + rect.Width); + Assert.IsLessThanOrEqualTo(160.01, rect.Y + rect.Height); } } /// /// Verifies layout no overlapping rects real assembly. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Layout_NoOverlappingRects_RealAssembly() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var tree = SizeAnalyzer.BuildSizeTree(a); var rects = TreemapLayout.Layout(tree.Children, 0, 0, 800, 600); - Assert.NotEmpty(rects); + Assert.IsNotEmpty(rects); AssertNoOverlaps(rects); } /// /// Verifies assert no overlaps detects known overlap. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AssertNoOverlaps_DetectsKnownOverlap() { var node = new SizeNode("test", "test", 100, SizeNodeKind.Type, []); @@ -153,14 +165,15 @@ public void AssertNoOverlaps_DetectsKnownOverlap() new(0, 0, 50, 50, node), new(25, 25, 50, 50, node), }; - var ex = Assert.ThrowsAny(() => AssertNoOverlaps(overlapping)); + var ex = Assert.Throws(() => AssertNoOverlaps(overlapping)); Assert.Contains("overlap", ex.Message); } /// /// Verifies assert no overlaps allows adjacent rects. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void AssertNoOverlaps_AllowsAdjacentRects() { var node = new SizeNode("test", "test", 100, SizeNodeKind.Type, []); @@ -175,12 +188,13 @@ public void AssertNoOverlaps_AllowsAdjacentRects() /// /// Verifies layout rects match input nodes. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Layout_RectsMatchInputNodes() { var nodes = CreateTestNodes(5); var rects = TreemapLayout.Layout(nodes, 0, 0, 100, 100); - Assert.Equal(nodes.Count, rects.Count); + Assert.HasCount(nodes.Count, rects); } /// @@ -188,17 +202,18 @@ public void Layout_RectsMatchInputNodes() /// total rect area matches container area, no overlaps, edges reach bounds. /// See: https://github.com/willibrandon/dotsider/issues/134 /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Layout_RealAssembly_RectsCoverFullArea() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var tree = SizeAnalyzer.BuildSizeTree(a); const double width = 120; const double height = 30; var rects = TreemapLayout.Layout(tree.Children, 0, 0, width, height); - Assert.NotEmpty(rects); + Assert.IsNotEmpty(rects); AssertNoOverlaps(rects); AssertFullCoverage(rects, width, height); } @@ -208,17 +223,18 @@ public void Layout_RealAssembly_RectsCoverFullArea() /// and squarification decisions. /// See: https://github.com/willibrandon/dotsider/issues/134 /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Layout_RealAssembly_RectsCoverFullArea_LargeViewport() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var tree = SizeAnalyzer.BuildSizeTree(a); const double width = 240; const double height = 60; var rects = TreemapLayout.Layout(tree.Children, 0, 0, width, height); - Assert.NotEmpty(rects); + Assert.IsNotEmpty(rects); AssertNoOverlaps(rects); AssertFullCoverage(rects, width, height); } @@ -228,22 +244,23 @@ public void Layout_RealAssembly_RectsCoverFullArea_LargeViewport() /// Verifies the layout fix works at subtree levels, not just the root. /// See: https://github.com/willibrandon/dotsider/issues/134 /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Layout_RealAssembly_DrilledNamespace_RectsCoverFullArea() { - using var a = new AssemblyAnalyzer(samples.RichLibraryDll); + using var a = new AssemblyAnalyzer(Samples.RichLibraryDll); var tree = SizeAnalyzer.BuildSizeTree(a); // Find the first namespace with 3+ children for a meaningful drilled layout var ns = tree.Children.FirstOrDefault(c => c.Kind == SizeNodeKind.Namespace && c.Children.Count >= 3); - Assert.NotNull(ns); + Assert.IsNotNull(ns); const double width = 120; const double height = 30; var rects = TreemapLayout.Layout(ns.Children, 0, 0, width, height); - Assert.NotEmpty(rects); + Assert.IsNotEmpty(rects); AssertNoOverlaps(rects); AssertFullCoverage(rects, width, height); } @@ -261,13 +278,13 @@ private static List CreateTestNodes(int count) private static void AssertFullCoverage(IReadOnlyList rects, double width, double height) { var totalRectArea = rects.Sum(r => r.Width * r.Height); - Assert.Equal(width * height, totalRectArea, 0.01); + Assert.AreEqual(width * height, totalRectArea, 0.01); var maxRight = rects.Max(r => r.X + r.Width); - Assert.Equal(width, maxRight, 0.01); + Assert.AreEqual(width, maxRight, 0.01); var maxBottom = rects.Max(r => r.Y + r.Height); - Assert.Equal(height, maxBottom, 0.01); + Assert.AreEqual(height, maxBottom, 0.01); } private static void AssertNoOverlaps(IReadOnlyList rects) diff --git a/tests/Dotsider.Tests/TuiArgParserTests.cs b/tests/Dotsider.Tests/TuiArgParserTests.cs index ebe1e4fa..c54fd583 100644 --- a/tests/Dotsider.Tests/TuiArgParserTests.cs +++ b/tests/Dotsider.Tests/TuiArgParserTests.cs @@ -5,176 +5,177 @@ namespace Dotsider.Tests; /// /// Tests for Tui Arg Parser. /// +[TestClass] public class TuiArgParserTests { /// /// Verifies parse file only returns defaults. /// - [Fact] + [TestMethod] public void Parse_FileOnly_ReturnsDefaults() { var result = TuiArgParser.Parse(["app.dll"], "app.dll"); - Assert.Equal("app.dll", result.FilePath); - Assert.Equal(0, result.InitialTab); - Assert.Equal(4, result.MinStringLength); + Assert.AreEqual("app.dll", result.FilePath); + Assert.AreEqual(0, result.InitialTab); + Assert.AreEqual(4, result.MinStringLength); } /// /// Verifies parse tab before file parses tab. /// - [Fact] + [TestMethod] public void Parse_TabBeforeFile_ParsesTab() { var result = TuiArgParser.Parse(["--tab", "3", "app.dll"], "app.dll"); - Assert.Equal(2, result.InitialTab); // 1-indexed input, 0-indexed output + Assert.AreEqual(2, result.InitialTab); // 1-indexed input, 0-indexed output } /// /// Verifies parse tab after file parses tab. /// - [Fact] + [TestMethod] public void Parse_TabAfterFile_ParsesTab() { var result = TuiArgParser.Parse(["app.dll", "--tab", "5"], "app.dll"); - Assert.Equal(4, result.InitialTab); + Assert.AreEqual(4, result.InitialTab); } /// /// Verifies parse short tab before file parses tab. /// - [Fact] + [TestMethod] public void Parse_ShortTabBeforeFile_ParsesTab() { var result = TuiArgParser.Parse(["-t", "2", "app.dll"], "app.dll"); - Assert.Equal(1, result.InitialTab); + Assert.AreEqual(1, result.InitialTab); } /// /// Verifies parse min len before file parses min len. /// - [Fact] + [TestMethod] public void Parse_MinLenBeforeFile_ParsesMinLen() { var result = TuiArgParser.Parse(["--min-len", "10", "app.dll"], "app.dll"); - Assert.Equal(10, result.MinStringLength); + Assert.AreEqual(10, result.MinStringLength); } /// /// Verifies parse short min len before file parses min len. /// - [Fact] + [TestMethod] public void Parse_ShortMinLenBeforeFile_ParsesMinLen() { var result = TuiArgParser.Parse(["-n", "8", "app.dll"], "app.dll"); - Assert.Equal(8, result.MinStringLength); + Assert.AreEqual(8, result.MinStringLength); } /// /// Verifies parse all options before file parses both. /// - [Fact] + [TestMethod] public void Parse_AllOptionsBeforeFile_ParsesBoth() { var result = TuiArgParser.Parse(["-t", "4", "-n", "12", "app.dll"], "app.dll"); - Assert.Equal(3, result.InitialTab); - Assert.Equal(12, result.MinStringLength); + Assert.AreEqual(3, result.InitialTab); + Assert.AreEqual(12, result.MinStringLength); } /// /// Verifies parse mixed ordering parses both. /// - [Fact] + [TestMethod] public void Parse_MixedOrdering_ParsesBoth() { var result = TuiArgParser.Parse(["--tab", "7", "app.dll", "--min-len", "6"], "app.dll"); - Assert.Equal(6, result.InitialTab); - Assert.Equal(6, result.MinStringLength); + Assert.AreEqual(6, result.InitialTab); + Assert.AreEqual(6, result.MinStringLength); } /// /// Verifies parse tab out of range clamps. /// - [Fact] + [TestMethod] public void Parse_TabOutOfRange_Clamps() { var high = TuiArgParser.Parse(["app.dll", "-t", "99"], "app.dll"); var low = TuiArgParser.Parse(["app.dll", "-t", "0"], "app.dll"); - Assert.Equal(7, high.InitialTab); // clamped to max (index 7 = tab 8) - Assert.Equal(0, low.InitialTab); // clamped to min (index 0 = tab 1) + Assert.AreEqual(7, high.InitialTab); // clamped to max (index 7 = tab 8) + Assert.AreEqual(0, low.InitialTab); // clamped to min (index 0 = tab 1) } /// /// Verifies parse invalid tab value keeps default. /// - [Fact] + [TestMethod] public void Parse_InvalidTabValue_KeepsDefault() { var result = TuiArgParser.Parse(["app.dll", "--tab", "abc"], "app.dll"); - Assert.Equal(0, result.InitialTab); + Assert.AreEqual(0, result.InitialTab); } /// /// Verifies parse escape timeout parses. /// - [Fact] + [TestMethod] public void Parse_EscapeTimeout_Parses() { var result = TuiArgParser.Parse(["--escape-timeout", "200", "app.dll"], "app.dll"); - Assert.Equal(200, result.EscapeTimeoutMs); + Assert.AreEqual(200, result.EscapeTimeoutMs); } /// /// Verifies parse short escape timeout parses. /// - [Fact] + [TestMethod] public void Parse_ShortEscapeTimeout_Parses() { var result = TuiArgParser.Parse(["-e", "75", "app.dll"], "app.dll"); - Assert.Equal(75, result.EscapeTimeoutMs); + Assert.AreEqual(75, result.EscapeTimeoutMs); } /// /// Verifies parse escape timeout below min clamps. /// - [Fact] + [TestMethod] public void Parse_EscapeTimeoutBelowMin_Clamps() { var result = TuiArgParser.Parse(["-e", "5", "app.dll"], "app.dll"); - Assert.Equal(10, result.EscapeTimeoutMs); + Assert.AreEqual(10, result.EscapeTimeoutMs); } /// /// Verifies parse escape timeout invalid ignores non numeric. /// - [Fact] + [TestMethod] public void Parse_EscapeTimeoutInvalid_IgnoresNonNumeric() { var result = TuiArgParser.Parse(["--escape-timeout", "abc", "app.dll"], "app.dll"); - Assert.Equal(100, result.EscapeTimeoutMs); + Assert.AreEqual(100, result.EscapeTimeoutMs); } /// /// Verifies parse file only escape timeout default. /// - [Fact] + [TestMethod] public void Parse_FileOnly_EscapeTimeoutDefault() { var result = TuiArgParser.Parse(["app.dll"], "app.dll"); - Assert.Equal(100, result.EscapeTimeoutMs); + Assert.AreEqual(100, result.EscapeTimeoutMs); } } diff --git a/tests/Dotsider.Tests/WasmSdkModuleDecoderTests.cs b/tests/Dotsider.Tests/WasmSdkModuleDecoderTests.cs index 7c00ffca..e3139016 100644 --- a/tests/Dotsider.Tests/WasmSdkModuleDecoderTests.cs +++ b/tests/Dotsider.Tests/WasmSdkModuleDecoderTests.cs @@ -9,48 +9,52 @@ namespace Dotsider.Tests; /// The fixture publish emits dotnet.native.wasm and dotnet.native.js.symbols. /// These tests cover the public analyzer path users exercise when they open the module directly. /// -[Collection("SampleAssemblies")] -public sealed class WasmSdkModuleDecoderTests(SampleAssemblyFixture samples) +[TestClass] +public sealed class WasmSdkModuleDecoderTests { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + /// /// Opening dotnet.native.wasm classifies the file as WebAssembly, reports Wasm32, /// and preserves the module's section, import, export, function, and symbol-map facts. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void BrowserWasmNativeModule_OpensAsWasmModule() { using var analyzer = OpenWasmFixture(); - Assert.False(analyzer.HasMetadata); - Assert.Equal(BinaryKind.Wasm, analyzer.BinaryKind); - Assert.Equal("Wasm32", analyzer.Architecture); + Assert.IsFalse(analyzer.HasMetadata); + Assert.AreEqual(BinaryKind.Wasm, analyzer.BinaryKind); + Assert.AreEqual("Wasm32", analyzer.Architecture); var wasm = analyzer.WasmModuleInfo; - Assert.NotNull(wasm); - Assert.Equal(1, wasm.Version); - Assert.NotEmpty(wasm.Sections); - Assert.True(wasm.ImportedFunctionCount > 0); - Assert.True(wasm.DefinedFunctionCount > 0); - Assert.True(wasm.CodeSize > 0); - Assert.True(wasm.DataSize > 0); - Assert.Equal(WasmSymbolMapStatus.Loaded, wasm.SymbolMapStatus); - Assert.True(wasm.SymbolMapEntryCount > wasm.DefinedFunctionCount / 2); + Assert.IsNotNull(wasm); + Assert.AreEqual(1, wasm.Version); + Assert.IsNotEmpty(wasm.Sections); + Assert.IsGreaterThan(0, wasm.ImportedFunctionCount); + Assert.IsGreaterThan(0, wasm.DefinedFunctionCount); + Assert.IsGreaterThan(0, wasm.CodeSize); + Assert.IsGreaterThan(0, wasm.DataSize); + Assert.AreEqual(WasmSymbolMapStatus.Loaded, wasm.SymbolMapStatus); + Assert.IsGreaterThan(wasm.DefinedFunctionCount / 2, wasm.SymbolMapEntryCount); } /// /// The raw Wasm reader parses standard SDK-emitted sections into model facts instead of only /// preserving their raw section headers. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void BrowserWasmNativeModule_ParsesStandardSections() { using var analyzer = OpenWasmFixture(); var wasm = analyzer.WasmModuleInfo; - Assert.NotNull(wasm); - Assert.NotEmpty(wasm.Types); - Assert.All(wasm.Functions.Where(static f => f.TypeIndex is not null), f => - Assert.InRange(f.TypeIndex!.Value, 0, wasm.Types.Count - 1)); + Assert.IsNotNull(wasm); + Assert.IsNotEmpty(wasm.Types); + TestAssert.All(wasm.Functions.Where(static f => f.TypeIndex is not null), f => + Assert.IsInRange(0, wasm.Types.Count - 1, f.TypeIndex!.Value)); AssertParsedWhenSectionExists(wasm, 4, wasm.Tables.Count, "table"); AssertParsedWhenSectionExists(wasm, 5, wasm.Memories.Count, "memory"); @@ -60,51 +64,53 @@ public void BrowserWasmNativeModule_ParsesStandardSections() AssertDefinedIndexesStartAfterImports(wasm); if (wasm.Sections.Any(static s => s.Id == 8)) - Assert.NotNull(wasm.StartFunctionIndex); + Assert.IsNotNull(wasm.StartFunctionIndex); if (wasm.Sections.Any(static s => s.Id == 12)) - Assert.Equal(wasm.DataSegments.Count, wasm.DataCount); + Assert.AreEqual(wasm.DataSegments.Count, wasm.DataCount); } /// /// A browser-wasm publish with RunAOTCompilation=true still opens through the raw /// WebAssembly path and exposes real file-backed Wasm functions. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void BrowserWasmAotNativeModule_OpensAsWasmModule() { - Assert.SkipWhen(samples.WasmConsoleAotNativeWasm is null, + TestSkip.When(Samples.WasmConsoleAotNativeWasm is null, "browser-wasm AOT publish did not run on this leg."); - using var analyzer = new AssemblyAnalyzer(samples.WasmConsoleAotNativeWasm!); + using var analyzer = new AssemblyAnalyzer(Samples.WasmConsoleAotNativeWasm!); - Assert.False(analyzer.HasMetadata); - Assert.Equal(BinaryKind.Wasm, analyzer.BinaryKind); + Assert.IsFalse(analyzer.HasMetadata); + Assert.AreEqual(BinaryKind.Wasm, analyzer.BinaryKind); var wasm = analyzer.WasmModuleInfo; - Assert.NotNull(wasm); - Assert.True(wasm.DefinedFunctionCount > 0); - Assert.True(wasm.CodeSize > 0); - Assert.NotEmpty(analyzer.NativeSymbols?.Symbols ?? []); + Assert.IsNotNull(wasm); + Assert.IsGreaterThan(0, wasm.DefinedFunctionCount); + Assert.IsGreaterThan(0, wasm.CodeSize); + Assert.IsNotEmpty(analyzer.NativeSymbols?.Symbols ?? []); } /// /// Opening a Webcil-wrapped .wasm app assembly unwraps to managed metadata and IL /// instead of presenting the wrapper as native runtime WebAssembly. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void BrowserWasmWebcilAssembly_OpensAsManagedMetadata() { using var analyzer = OpenWebcilFixture(); - Assert.True(analyzer.HasMetadata); - Assert.Equal(BinaryKind.Managed, analyzer.BinaryKind); - Assert.NotNull(analyzer.WebcilInfo); - Assert.Null(analyzer.WasmModuleInfo); - Assert.Contains(analyzer.TypeDefs, static t => t.FullName == "WasmCalculator"); + Assert.IsTrue(analyzer.HasMetadata); + Assert.AreEqual(BinaryKind.Managed, analyzer.BinaryKind); + Assert.IsNotNull(analyzer.WebcilInfo); + Assert.IsNull(analyzer.WasmModuleInfo); + Assert.Contains(static t => t.FullName == "WasmCalculator", analyzer.TypeDefs); var method = analyzer.MethodDefs.First(static m => m.DeclaringType == "WasmCalculator" && m.Name == "Add"); var il = new IlDisassembler(analyzer).DisassembleWithText(method); - Assert.NotNull(il); + Assert.IsNotNull(il); Assert.Contains("IL_", il.Value.Text); Assert.Contains("ldarg", il.Value.Text); } @@ -113,23 +119,24 @@ public void BrowserWasmWebcilAssembly_OpensAsManagedMetadata() /// WebAssembly functions become native symbols with WebAssembly provenance and file offsets. /// The symbol names come from the SDK sidecar when dotnet.native.js.symbols is present. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void BrowserWasmNativeModule_NativeSymbolsUseSymbolMap() { using var analyzer = OpenWasmFixture(); var info = analyzer.NativeSymbols; - Assert.NotNull(info); - Assert.Equal(NativeSymbolSource.WebAssembly, info.Source); - Assert.Equal(NativeSymbolStatus.Loaded, info.Status); - Assert.Equal(NativeArchitecture.Wasm32, info.Architecture); - Assert.NotNull(info.Path); - Assert.NotEmpty(info.Symbols); - Assert.All(info.Symbols.Take(100), symbol => + Assert.IsNotNull(info); + Assert.AreEqual(NativeSymbolSource.WebAssembly, info.Source); + Assert.AreEqual(NativeSymbolStatus.Loaded, info.Status); + Assert.AreEqual(NativeArchitecture.Wasm32, info.Architecture); + Assert.IsNotNull(info.Path); + Assert.IsNotEmpty(info.Symbols); + TestAssert.All(info.Symbols.Take(100), symbol => { - Assert.Equal(NativeSymbolKind.Function, symbol.Kind); - Assert.NotNull(symbol.FileOffset); - Assert.True(symbol.Size > 0); + Assert.AreEqual(NativeSymbolKind.Function, symbol.Kind); + Assert.IsNotNull(symbol.FileOffset); + Assert.IsGreaterThan(0, symbol.Size); }); } @@ -137,34 +144,36 @@ public void BrowserWasmNativeModule_NativeSymbolsUseSymbolMap() /// Disassembling a real SDK-produced Wasm function produces full instruction coverage and /// resolves direct call operands to imported or defined function names. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void BrowserWasmNativeModule_DisassemblesRealFunctionAndNamesDirectCalls() { using var analyzer = OpenWasmFixture(); var symbol = FindFunctionWithNamedCall(analyzer); var result = NativeDisassembler.DisassembleSymbol(analyzer, symbol); - Assert.NotNull(result); + Assert.IsNotNull(result); var instructions = result.Value.Instructions; - Assert.NotEmpty(instructions); - Assert.DoesNotContain(instructions, static instruction => instruction.IsFallback); - Assert.Equal(symbol.Size, instructions.Sum(static instruction => instruction.Length)); - Assert.Contains(instructions, static instruction => + Assert.IsNotEmpty(instructions); + Assert.DoesNotContain(static instruction => instruction.IsFallback, instructions); + Assert.AreEqual(symbol.Size, instructions.Sum(static instruction => instruction.Length)); + Assert.Contains(static instruction => instruction.Mnemonic is "call" or "return_call" - && instruction.TargetName is not null); + && instruction.TargetName is not null, instructions); } /// /// Wasm disassembly annotates function-index, local, and table/type operands without treating /// function indexes as native virtual addresses. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void BrowserWasmNativeModule_DisassemblyAnnotatesWasmOperands() { using var analyzer = OpenWasmFixture(); var info = analyzer.NativeSymbols; - Assert.NotNull(info); + Assert.IsNotNull(info); var annotated = info.Symbols .Take(512) @@ -174,52 +183,53 @@ public void BrowserWasmNativeModule_DisassemblyAnnotatesWasmOperands() .Where(static instruction => instruction.OperandText.Contains('<', StringComparison.Ordinal)) .ToList(); - Assert.Contains(annotated, static instruction => + Assert.Contains(static instruction => instruction.Mnemonic is "call" or "return_call" - && instruction.TargetName is not null); - Assert.Contains(annotated, static instruction => - instruction.Mnemonic is "local.get" or "local.set" or "local.tee"); + && instruction.TargetName is not null, annotated); + Assert.Contains(static instruction => + instruction.Mnemonic is "local.get" or "local.set" or "local.tee", annotated); } /// /// The Size Map treats a raw Wasm module as native code: function bodies, data segments, and /// remaining sections are sized from file-backed Wasm payloads rather than managed IL. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void BrowserWasmNativeModule_SizeTreeUsesWasmPayloads() { using var analyzer = OpenWasmFixture(); var tree = SizeAnalyzer.BuildSizeTree(analyzer); - Assert.True(tree.Name.EndsWith("(Wasm)", StringComparison.Ordinal), tree.Name); - Assert.True(tree.Size > 0); - Assert.Contains(tree.Children, static child => child.Name == "Functions" && child.Size > 0); - Assert.Contains(tree.Children, static child => child.Name == "Data" && child.Size > 0); - Assert.Contains(tree.Children, static child => child.Name == "Sections" && child.Size > 0); + Assert.IsTrue(tree.Name.EndsWith("(Wasm)", StringComparison.Ordinal), tree.Name); + Assert.IsGreaterThan(0, tree.Size); + Assert.Contains(static child => child.Name == "Functions" && child.Size > 0, tree.Children); + Assert.Contains(static child => child.Name == "Data" && child.Size > 0, tree.Children); + Assert.Contains(static child => child.Name == "Sections" && child.Size > 0, tree.Children); } - private AssemblyAnalyzer OpenWasmFixture() + private static AssemblyAnalyzer OpenWasmFixture() { - Assert.SkipWhen( - samples.WasmConsoleNativeWasm is null && samples.ReadyToRunConsoleWasmNativeWasm is null, + TestSkip.When( + Samples.WasmConsoleNativeWasm is null && Samples.ReadyToRunConsoleWasmNativeWasm is null, "browser-wasm publish did not run on this leg."); - return new AssemblyAnalyzer(samples.WasmConsoleNativeWasm ?? samples.ReadyToRunConsoleWasmNativeWasm!); + return new AssemblyAnalyzer(Samples.WasmConsoleNativeWasm ?? Samples.ReadyToRunConsoleWasmNativeWasm!); } - private AssemblyAnalyzer OpenWebcilFixture() + private static AssemblyAnalyzer OpenWebcilFixture() { - Assert.SkipWhen( - samples.WasmConsoleWebcilWasm is null, + TestSkip.When( + Samples.WasmConsoleWebcilWasm is null, "browser-wasm publish did not produce the Webcil app assembly on this leg."); - return new AssemblyAnalyzer(samples.WasmConsoleWebcilWasm!); + return new AssemblyAnalyzer(Samples.WasmConsoleWebcilWasm!); } private static NativeSymbol FindFunctionWithNamedCall(AssemblyAnalyzer analyzer) { var info = analyzer.NativeSymbols; - Assert.NotNull(info); + Assert.IsNotNull(info); foreach (var symbol in info.Symbols.Take(512)) { var result = NativeDisassembler.DisassembleSymbol(analyzer, symbol); @@ -240,7 +250,7 @@ instruction.Mnemonic is "call" or "return_call" private static void AssertParsedWhenSectionExists(WasmModuleInfo wasm, byte sectionId, int parsedCount, string name) { if (wasm.Sections.Any(s => s.Id == sectionId)) - Assert.True(parsedCount > 0, $"Expected parsed {name} entries for section {sectionId}."); + Assert.IsGreaterThan(0, parsedCount, $"Expected parsed {name} entries for section {sectionId}."); } private static void AssertDefinedIndexesStartAfterImports(WasmModuleInfo wasm) @@ -269,7 +279,7 @@ private static void AssertIndexesStartAfterImports(int importCount, IEnumerable< if (values.Count == 0) return; - Assert.All(values, index => - Assert.True(index >= importCount, $"{label} index {index} should account for {importCount} imports.")); + TestAssert.All(values, index => + Assert.IsGreaterThanOrEqualTo(importCount, index, $"{label} index {index} should account for {importCount} imports.")); } } diff --git a/tests/Dotsider.Tests/XarchAvx512Tests.cs b/tests/Dotsider.Tests/XarchAvx512Tests.cs index adbbd7eb..201c6ee0 100644 --- a/tests/Dotsider.Tests/XarchAvx512Tests.cs +++ b/tests/Dotsider.Tests/XarchAvx512Tests.cs @@ -8,37 +8,40 @@ namespace Dotsider.Tests; /// {k}/{z} decoration, the EVEX-only ops (ternary logic, VNNI, conflict/lzcnt, scale, mask /// compares), and the VEX-encoded opmask moves — cross-checked against objdump. /// +[TestClass] public class XarchAvx512Tests { private static NativeInstruction One(params byte[] code) => NativeDisassembler.Disassemble(code, 0x1000, NativeArchitecture.X64)[0]; /// Decodes representative EVEX-encoded AVX-512 ops to their mnemonics, operands, and length. - [Theory(Timeout = 30_000)] - [InlineData("vpaddd", "zmm0, zmm1, zmm2", new byte[] { 0x62, 0xF1, 0x75, 0x48, 0xFE, 0xC2 })] - [InlineData("vpaddd", "zmm0{k1}, zmm1, zmm2", new byte[] { 0x62, 0xF1, 0x75, 0x49, 0xFE, 0xC2 })] - [InlineData("vpternlogd", "zmm0, zmm1, zmm2, 0xff", new byte[] { 0x62, 0xF3, 0x75, 0x48, 0x25, 0xC2, 0xFF })] - [InlineData("vpdpbusd", "zmm0, zmm1, zmm2", new byte[] { 0x62, 0xF2, 0x75, 0x48, 0x50, 0xC2 })] - [InlineData("vpcmpd", "k1, zmm1, zmm2, 0x0", new byte[] { 0x62, 0xF3, 0x75, 0x48, 0x1F, 0xCA, 0x00 })] - [InlineData("vscalefps", "zmm0, zmm1, zmm2", new byte[] { 0x62, 0xF2, 0x75, 0x48, 0x2C, 0xC2 })] - [InlineData("vplzcntq", "zmm0, zmm1", new byte[] { 0x62, 0xF2, 0xFD, 0x48, 0x44, 0xC1 })] - [InlineData("vpxord", "zmm0, zmm1, zmm2", new byte[] { 0x62, 0xF1, 0x75, 0x48, 0xEF, 0xC2 })] - [InlineData("kmovw", "k1, k2", new byte[] { 0xC5, 0xF8, 0x90, 0xCA })] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow("vpaddd", "zmm0, zmm1, zmm2", new byte[] { 0x62, 0xF1, 0x75, 0x48, 0xFE, 0xC2 })] + [DataRow("vpaddd", "zmm0{k1}, zmm1, zmm2", new byte[] { 0x62, 0xF1, 0x75, 0x49, 0xFE, 0xC2 })] + [DataRow("vpternlogd", "zmm0, zmm1, zmm2, 0xff", new byte[] { 0x62, 0xF3, 0x75, 0x48, 0x25, 0xC2, 0xFF })] + [DataRow("vpdpbusd", "zmm0, zmm1, zmm2", new byte[] { 0x62, 0xF2, 0x75, 0x48, 0x50, 0xC2 })] + [DataRow("vpcmpd", "k1, zmm1, zmm2, 0x0", new byte[] { 0x62, 0xF3, 0x75, 0x48, 0x1F, 0xCA, 0x00 })] + [DataRow("vscalefps", "zmm0, zmm1, zmm2", new byte[] { 0x62, 0xF2, 0x75, 0x48, 0x2C, 0xC2 })] + [DataRow("vplzcntq", "zmm0, zmm1", new byte[] { 0x62, 0xF2, 0xFD, 0x48, 0x44, 0xC1 })] + [DataRow("vpxord", "zmm0, zmm1, zmm2", new byte[] { 0x62, 0xF1, 0x75, 0x48, 0xEF, 0xC2 })] + [DataRow("kmovw", "k1, k2", new byte[] { 0xC5, 0xF8, 0x90, 0xCA })] public void Decode_Avx512_MnemonicAndOperands(string mnemonic, string operands, byte[] code) { var insn = One(code); - Assert.False(insn.IsFallback); - Assert.Equal(mnemonic, insn.Mnemonic); - Assert.Equal(operands, insn.OperandText); - Assert.Equal(code.Length, insn.Length); + Assert.IsFalse(insn.IsFallback); + Assert.AreEqual(mnemonic, insn.Mnemonic); + Assert.AreEqual(operands, insn.OperandText); + Assert.AreEqual(code.Length, insn.Length); } /// Verifies EVEX.512 selects zmm registers and zeroing renders {z}. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Decode_Evex_ZmmAndZeroing() { // vpaddd zmm0{k1}{z}, zmm1, zmm2 — z bit set in P2. var insn = One(0x62, 0xF1, 0x75, 0xC9, 0xFE, 0xC2); - Assert.Equal("zmm0{k1}{z}, zmm1, zmm2", insn.OperandText); + Assert.AreEqual("zmm0{k1}{z}, zmm1, zmm2", insn.OperandText); } } diff --git a/tests/Dotsider.Tests/XarchAvxTests.cs b/tests/Dotsider.Tests/XarchAvxTests.cs index 607296ef..114455f9 100644 --- a/tests/Dotsider.Tests/XarchAvxTests.cs +++ b/tests/Dotsider.Tests/XarchAvxTests.cs @@ -8,40 +8,43 @@ namespace Dotsider.Tests; /// v-prefix, ymm registers appear on VEX.256, and the VEX-only ops (broadcasts, permutes, /// insert/extract-128, variable shifts, F16C) decode — cross-checked against objdump. /// +[TestClass] public class XarchAvxTests { private static NativeInstruction One(params byte[] code) => NativeDisassembler.Disassemble(code, 0x1000, NativeArchitecture.X64)[0]; /// Decodes representative VEX-encoded AVX/AVX2 ops to their mnemonics, operands, and length. - [Theory(Timeout = 30_000)] - [InlineData("vaddps", "ymm0, ymm1, ymm2", new byte[] { 0xC5, 0xF4, 0x58, 0xC2 })] - [InlineData("vaddsd", "xmm0, xmm1, xmm2", new byte[] { 0xC5, 0xF3, 0x58, 0xC2 })] - [InlineData("vpxor", "xmm0, xmm1, xmm2", new byte[] { 0xC5, 0xF1, 0xEF, 0xC2 })] - [InlineData("vmovaps", "ymm0, ymm1", new byte[] { 0xC5, 0xFC, 0x28, 0xC1 })] - [InlineData("vmovdqa", "ymm0, ymmword ptr [rcx]", new byte[] { 0xC5, 0xFD, 0x6F, 0x01 })] - [InlineData("vbroadcastss", "ymm0, xmm1", new byte[] { 0xC4, 0xE2, 0x7D, 0x18, 0xC1 })] - [InlineData("vinsertf128", "ymm0, ymm0, xmm2, 0x1", new byte[] { 0xC4, 0xE3, 0x7D, 0x18, 0xC2, 0x01 })] - [InlineData("vzeroupper", "", new byte[] { 0xC5, 0xF8, 0x77 })] - [InlineData("vzeroall", "", new byte[] { 0xC5, 0xFC, 0x77 })] - [InlineData("vpmulld", "ymm0, ymm1, ymm2", new byte[] { 0xC4, 0xE2, 0x75, 0x40, 0xC2 })] - [InlineData("vpsllvd", "xmm0, xmm1, xmm2", new byte[] { 0xC4, 0xE2, 0x71, 0x47, 0xC2 })] - [InlineData("vcvtph2ps", "ymm0, xmm1", new byte[] { 0xC4, 0xE2, 0x7D, 0x13, 0xC1 })] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow("vaddps", "ymm0, ymm1, ymm2", new byte[] { 0xC5, 0xF4, 0x58, 0xC2 })] + [DataRow("vaddsd", "xmm0, xmm1, xmm2", new byte[] { 0xC5, 0xF3, 0x58, 0xC2 })] + [DataRow("vpxor", "xmm0, xmm1, xmm2", new byte[] { 0xC5, 0xF1, 0xEF, 0xC2 })] + [DataRow("vmovaps", "ymm0, ymm1", new byte[] { 0xC5, 0xFC, 0x28, 0xC1 })] + [DataRow("vmovdqa", "ymm0, ymmword ptr [rcx]", new byte[] { 0xC5, 0xFD, 0x6F, 0x01 })] + [DataRow("vbroadcastss", "ymm0, xmm1", new byte[] { 0xC4, 0xE2, 0x7D, 0x18, 0xC1 })] + [DataRow("vinsertf128", "ymm0, ymm0, xmm2, 0x1", new byte[] { 0xC4, 0xE3, 0x7D, 0x18, 0xC2, 0x01 })] + [DataRow("vzeroupper", "", new byte[] { 0xC5, 0xF8, 0x77 })] + [DataRow("vzeroall", "", new byte[] { 0xC5, 0xFC, 0x77 })] + [DataRow("vpmulld", "ymm0, ymm1, ymm2", new byte[] { 0xC4, 0xE2, 0x75, 0x40, 0xC2 })] + [DataRow("vpsllvd", "xmm0, xmm1, xmm2", new byte[] { 0xC4, 0xE2, 0x71, 0x47, 0xC2 })] + [DataRow("vcvtph2ps", "ymm0, xmm1", new byte[] { 0xC4, 0xE2, 0x7D, 0x13, 0xC1 })] public void Decode_Avx_MnemonicAndOperands(string mnemonic, string operands, byte[] code) { var insn = One(code); - Assert.False(insn.IsFallback); - Assert.Equal(mnemonic, insn.Mnemonic); - Assert.Equal(operands, insn.OperandText); - Assert.Equal(code.Length, insn.Length); + Assert.IsFalse(insn.IsFallback); + Assert.AreEqual(mnemonic, insn.Mnemonic); + Assert.AreEqual(operands, insn.OperandText); + Assert.AreEqual(code.Length, insn.Length); } /// Verifies a VEX.256 op renders ymm registers and classifies as Vector. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Decode_Avx256_UsesYmmAndVectorCategory() { var insn = One(0xC5, 0xF4, 0x58, 0xC2); // vaddps ymm0, ymm1, ymm2 Assert.Contains("ymm", insn.OperandText); - Assert.Equal(NativeInstructionCategory.Vector, insn.Category); + Assert.AreEqual(NativeInstructionCategory.Vector, insn.Category); } } diff --git a/tests/Dotsider.Tests/XarchBmiFmaTests.cs b/tests/Dotsider.Tests/XarchBmiFmaTests.cs index a0224117..5dca64a5 100644 --- a/tests/Dotsider.Tests/XarchBmiFmaTests.cs +++ b/tests/Dotsider.Tests/XarchBmiFmaTests.cs @@ -8,32 +8,34 @@ namespace Dotsider.Tests; /// the VEX-encoded BMI ops that keep their plain mnemonics and take a GPR vvvv source — all /// cross-checked against objdump. /// +[TestClass] public class XarchBmiFmaTests { private static NativeInstruction One(params byte[] code) => NativeDisassembler.Disassemble(code, 0x1000, NativeArchitecture.X64)[0]; /// Decodes representative FMA and BMI/ADX ops to their mnemonics, operands, and length. - [Theory(Timeout = 30_000)] - [InlineData("vfmadd231ps", "xmm0, xmm1, xmm2", new byte[] { 0xC4, 0xE2, 0x71, 0xB8, 0xC2 })] - [InlineData("vfmadd231pd", "xmm0, xmm1, xmm2", new byte[] { 0xC4, 0xE2, 0xF1, 0xB8, 0xC2 })] - [InlineData("vfmadd213sd", "xmm0, xmm1, xmm2", new byte[] { 0xC4, 0xE2, 0xF1, 0xA9, 0xC2 })] - [InlineData("vfnmadd231ps", "xmm0, xmm1, xmm2", new byte[] { 0xC4, 0xE2, 0x71, 0xBC, 0xC2 })] - [InlineData("andn", "eax, ebx, ecx", new byte[] { 0xC4, 0xE2, 0x60, 0xF2, 0xC1 })] - [InlineData("blsr", "eax, ecx", new byte[] { 0xC4, 0xE2, 0x78, 0xF3, 0xC9 })] - [InlineData("blsi", "eax, ecx", new byte[] { 0xC4, 0xE2, 0x78, 0xF3, 0xD9 })] - [InlineData("mulx", "eax, ebx, ecx", new byte[] { 0xC4, 0xE2, 0x63, 0xF6, 0xC1 })] - [InlineData("rorx", "eax, ecx, 0x5", new byte[] { 0xC4, 0xE3, 0x7B, 0xF0, 0xC1, 0x05 })] - [InlineData("bextr", "eax, ecx, edx", new byte[] { 0xC4, 0xE2, 0x68, 0xF7, 0xC1 })] - [InlineData("popcnt", "eax, ecx", new byte[] { 0xF3, 0x0F, 0xB8, 0xC1 })] - [InlineData("adcx", "eax, ecx", new byte[] { 0x66, 0x0F, 0x38, 0xF6, 0xC1 })] - [InlineData("adox", "eax, ecx", new byte[] { 0xF3, 0x0F, 0x38, 0xF6, 0xC1 })] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow("vfmadd231ps", "xmm0, xmm1, xmm2", new byte[] { 0xC4, 0xE2, 0x71, 0xB8, 0xC2 })] + [DataRow("vfmadd231pd", "xmm0, xmm1, xmm2", new byte[] { 0xC4, 0xE2, 0xF1, 0xB8, 0xC2 })] + [DataRow("vfmadd213sd", "xmm0, xmm1, xmm2", new byte[] { 0xC4, 0xE2, 0xF1, 0xA9, 0xC2 })] + [DataRow("vfnmadd231ps", "xmm0, xmm1, xmm2", new byte[] { 0xC4, 0xE2, 0x71, 0xBC, 0xC2 })] + [DataRow("andn", "eax, ebx, ecx", new byte[] { 0xC4, 0xE2, 0x60, 0xF2, 0xC1 })] + [DataRow("blsr", "eax, ecx", new byte[] { 0xC4, 0xE2, 0x78, 0xF3, 0xC9 })] + [DataRow("blsi", "eax, ecx", new byte[] { 0xC4, 0xE2, 0x78, 0xF3, 0xD9 })] + [DataRow("mulx", "eax, ebx, ecx", new byte[] { 0xC4, 0xE2, 0x63, 0xF6, 0xC1 })] + [DataRow("rorx", "eax, ecx, 0x5", new byte[] { 0xC4, 0xE3, 0x7B, 0xF0, 0xC1, 0x05 })] + [DataRow("bextr", "eax, ecx, edx", new byte[] { 0xC4, 0xE2, 0x68, 0xF7, 0xC1 })] + [DataRow("popcnt", "eax, ecx", new byte[] { 0xF3, 0x0F, 0xB8, 0xC1 })] + [DataRow("adcx", "eax, ecx", new byte[] { 0x66, 0x0F, 0x38, 0xF6, 0xC1 })] + [DataRow("adox", "eax, ecx", new byte[] { 0xF3, 0x0F, 0x38, 0xF6, 0xC1 })] public void Decode_BmiFma_MnemonicAndOperands(string mnemonic, string operands, byte[] code) { var insn = One(code); - Assert.False(insn.IsFallback); - Assert.Equal(mnemonic, insn.Mnemonic); - Assert.Equal(operands, insn.OperandText); - Assert.Equal(code.Length, insn.Length); + Assert.IsFalse(insn.IsFallback); + Assert.AreEqual(mnemonic, insn.Mnemonic); + Assert.AreEqual(operands, insn.OperandText); + Assert.AreEqual(code.Length, insn.Length); } } diff --git a/tests/Dotsider.Tests/XarchCryptoTests.cs b/tests/Dotsider.Tests/XarchCryptoTests.cs index d69437ad..9b3abee8 100644 --- a/tests/Dotsider.Tests/XarchCryptoTests.cs +++ b/tests/Dotsider.Tests/XarchCryptoTests.cs @@ -8,29 +8,31 @@ namespace Dotsider.Tests; /// VPCLMULQDQ forms that gain their vvvv source — cross-checked against objdump (whose fused /// pclmul immediate names normalize to the generic form). /// +[TestClass] public class XarchCryptoTests { private static NativeInstruction One(params byte[] code) => NativeDisassembler.Disassemble(code, 0x1000, NativeArchitecture.X64)[0]; /// Decodes the crypto/GF opcodes to their mnemonics, operands, and length. - [Theory(Timeout = 30_000)] - [InlineData("aesenc", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0x38, 0xDC, 0xC1 })] - [InlineData("aesenclast", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0x38, 0xDD, 0xC1 })] - [InlineData("aesdec", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0x38, 0xDE, 0xC1 })] - [InlineData("aesimc", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0x38, 0xDB, 0xC1 })] - [InlineData("aeskeygenassist", "xmm0, xmm1, 0x1", new byte[] { 0x66, 0x0F, 0x3A, 0xDF, 0xC1, 0x01 })] - [InlineData("pclmulqdq", "xmm0, xmm1, 0x11", new byte[] { 0x66, 0x0F, 0x3A, 0x44, 0xC1, 0x11 })] - [InlineData("vaesenc", "xmm0, xmm1, xmm2", new byte[] { 0xC4, 0xE2, 0x71, 0xDC, 0xC2 })] - [InlineData("vpclmulqdq", "xmm0, xmm1, xmm2, 0x10", new byte[] { 0xC4, 0xE3, 0x71, 0x44, 0xC2, 0x10 })] - [InlineData("gf2p8mulb", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0x38, 0xCF, 0xC1 })] - [InlineData("gf2p8affineqb", "xmm0, xmm1, 0x5", new byte[] { 0x66, 0x0F, 0x3A, 0xCE, 0xC1, 0x05 })] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow("aesenc", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0x38, 0xDC, 0xC1 })] + [DataRow("aesenclast", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0x38, 0xDD, 0xC1 })] + [DataRow("aesdec", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0x38, 0xDE, 0xC1 })] + [DataRow("aesimc", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0x38, 0xDB, 0xC1 })] + [DataRow("aeskeygenassist", "xmm0, xmm1, 0x1", new byte[] { 0x66, 0x0F, 0x3A, 0xDF, 0xC1, 0x01 })] + [DataRow("pclmulqdq", "xmm0, xmm1, 0x11", new byte[] { 0x66, 0x0F, 0x3A, 0x44, 0xC1, 0x11 })] + [DataRow("vaesenc", "xmm0, xmm1, xmm2", new byte[] { 0xC4, 0xE2, 0x71, 0xDC, 0xC2 })] + [DataRow("vpclmulqdq", "xmm0, xmm1, xmm2, 0x10", new byte[] { 0xC4, 0xE3, 0x71, 0x44, 0xC2, 0x10 })] + [DataRow("gf2p8mulb", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0x38, 0xCF, 0xC1 })] + [DataRow("gf2p8affineqb", "xmm0, xmm1, 0x5", new byte[] { 0x66, 0x0F, 0x3A, 0xCE, 0xC1, 0x05 })] public void Decode_Crypto_MnemonicAndOperands(string mnemonic, string operands, byte[] code) { var insn = One(code); - Assert.False(insn.IsFallback); - Assert.Equal(mnemonic, insn.Mnemonic); - Assert.Equal(operands, insn.OperandText); - Assert.Equal(code.Length, insn.Length); + Assert.IsFalse(insn.IsFallback); + Assert.AreEqual(mnemonic, insn.Mnemonic); + Assert.AreEqual(operands, insn.OperandText); + Assert.AreEqual(code.Length, insn.Length); } } diff --git a/tests/Dotsider.Tests/XarchDecoderTests.cs b/tests/Dotsider.Tests/XarchDecoderTests.cs index 5e9f6c54..e9529546 100644 --- a/tests/Dotsider.Tests/XarchDecoderTests.cs +++ b/tests/Dotsider.Tests/XarchDecoderTests.cs @@ -11,6 +11,7 @@ namespace Dotsider.Tests; /// completeness, and the one-byte fallback resync. Real vectorized code is validated by the /// objdump oracle and the AOT fixtures. /// +[TestClass] public class XarchDecoderTests { private static NativeInstruction One(ulong address, params byte[] code) @@ -20,93 +21,97 @@ private static NativeInstruction One(ulong address, params byte[] code) } /// Verifies the representative legacy surface decodes to exact mnemonics and operands. - [Theory(Timeout = 30_000)] - [InlineData("nop", "", new byte[] { 0x90 })] - [InlineData("int3", "", new byte[] { 0xCC })] - [InlineData("ret", "", new byte[] { 0xC3 })] - [InlineData("push", "rbp", new byte[] { 0x55 })] - [InlineData("mov", "rbp, rsp", new byte[] { 0x48, 0x89, 0xE5 })] - [InlineData("sub", "rsp, 0x20", new byte[] { 0x48, 0x83, 0xEC, 0x20 })] - [InlineData("mov", "rax, qword ptr [rbp-0x8]", new byte[] { 0x48, 0x8B, 0x45, 0xF8 })] - [InlineData("mov", "eax, dword ptr [rax+rcx*4]", new byte[] { 0x8B, 0x04, 0x88 })] - [InlineData("mov", "rax, 0x1", new byte[] { 0x48, 0xC7, 0xC0, 0x01, 0x00, 0x00, 0x00 })] - [InlineData("movabs", "rax, 0x8877665544332211", + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow("nop", "", new byte[] { 0x90 })] + [DataRow("int3", "", new byte[] { 0xCC })] + [DataRow("ret", "", new byte[] { 0xC3 })] + [DataRow("push", "rbp", new byte[] { 0x55 })] + [DataRow("mov", "rbp, rsp", new byte[] { 0x48, 0x89, 0xE5 })] + [DataRow("sub", "rsp, 0x20", new byte[] { 0x48, 0x83, 0xEC, 0x20 })] + [DataRow("mov", "rax, qword ptr [rbp-0x8]", new byte[] { 0x48, 0x8B, 0x45, 0xF8 })] + [DataRow("mov", "eax, dword ptr [rax+rcx*4]", new byte[] { 0x8B, 0x04, 0x88 })] + [DataRow("mov", "rax, 0x1", new byte[] { 0x48, 0xC7, 0xC0, 0x01, 0x00, 0x00, 0x00 })] + [DataRow("movabs", "rax, 0x8877665544332211", new byte[] { 0x48, 0xB8, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88 })] - [InlineData("inc", "rax", new byte[] { 0x48, 0xFF, 0xC0 })] - [InlineData("shl", "eax, 0x4", new byte[] { 0xC1, 0xE0, 0x04 })] - [InlineData("test", "eax, 0x1", new byte[] { 0xF7, 0xC0, 0x01, 0x00, 0x00, 0x00 })] - [InlineData("add", "rax, 0xffffffffffffffff", new byte[] { 0x48, 0x83, 0xC0, 0xFF })] - [InlineData("fld", "dword ptr [rbp-0x8]", new byte[] { 0xD9, 0x45, 0xF8 })] - [InlineData("lea", "rax, qword ptr [rip+0x0]", new byte[] { 0x48, 0x8D, 0x05, 0x00, 0x00, 0x00, 0x00 })] + [DataRow("inc", "rax", new byte[] { 0x48, 0xFF, 0xC0 })] + [DataRow("shl", "eax, 0x4", new byte[] { 0xC1, 0xE0, 0x04 })] + [DataRow("test", "eax, 0x1", new byte[] { 0xF7, 0xC0, 0x01, 0x00, 0x00, 0x00 })] + [DataRow("add", "rax, 0xffffffffffffffff", new byte[] { 0x48, 0x83, 0xC0, 0xFF })] + [DataRow("fld", "dword ptr [rbp-0x8]", new byte[] { 0xD9, 0x45, 0xF8 })] + [DataRow("lea", "rax, qword ptr [rip+0x0]", new byte[] { 0x48, 0x8D, 0x05, 0x00, 0x00, 0x00, 0x00 })] public void Decode_Legacy_MnemonicAndOperands(string mnemonic, string operands, byte[] code) { var insn = One(0x1000, code); - Assert.False(insn.IsFallback); - Assert.Equal(mnemonic, insn.Mnemonic); - Assert.Equal(operands, insn.OperandText); - Assert.Equal(code.Length, insn.Length); + Assert.IsFalse(insn.IsFallback); + Assert.AreEqual(mnemonic, insn.Mnemonic); + Assert.AreEqual(operands, insn.OperandText); + Assert.AreEqual(code.Length, insn.Length); } /// /// Verifies exact length across the prefix and addressing matrix — the invariant that keeps the /// listing in sync. Each blob is a single instruction whose byte count is its length. /// - [Theory(Timeout = 30_000)] - [InlineData(new byte[] { 0x48, 0x01, 0xC0 })] // REX.W add - [InlineData(new byte[] { 0x66, 0x01, 0xC0 })] // 66 opsize add ax - [InlineData(new byte[] { 0x67, 0x8B, 0x00 })] // 67 addrsize [eax] - [InlineData(new byte[] { 0x8B, 0x44, 0x88, 0x10 })] // SIB + disp8 - [InlineData(new byte[] { 0x8B, 0x84, 0x88, 0x00, 0x01, 0x00, 0x00 })] // SIB + disp32 - [InlineData(new byte[] { 0x48, 0x8B, 0x05, 0x00, 0x00, 0x00, 0x00 })] // RIP-relative - [InlineData(new byte[] { 0x81, 0xC0, 0x00, 0x01, 0x00, 0x00 })] // imm32 - [InlineData(new byte[] { 0x66, 0x81, 0xC0, 0x00, 0x01 })] // 66 imm16 - [InlineData(new byte[] { 0xEB, 0x10 })] // rel8 - [InlineData(new byte[] { 0xE9, 0x10, 0x00, 0x00, 0x00 })] // rel32 - [InlineData(new byte[] { 0x0F, 0x1F, 0x44, 0x00, 0x00 })] // multi-byte nop + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow(new byte[] { 0x48, 0x01, 0xC0 })] // REX.W add + [DataRow(new byte[] { 0x66, 0x01, 0xC0 })] // 66 opsize add ax + [DataRow(new byte[] { 0x67, 0x8B, 0x00 })] // 67 addrsize [eax] + [DataRow(new byte[] { 0x8B, 0x44, 0x88, 0x10 })] // SIB + disp8 + [DataRow(new byte[] { 0x8B, 0x84, 0x88, 0x00, 0x01, 0x00, 0x00 })] // SIB + disp32 + [DataRow(new byte[] { 0x48, 0x8B, 0x05, 0x00, 0x00, 0x00, 0x00 })] // RIP-relative + [DataRow(new byte[] { 0x81, 0xC0, 0x00, 0x01, 0x00, 0x00 })] // imm32 + [DataRow(new byte[] { 0x66, 0x81, 0xC0, 0x00, 0x01 })] // 66 imm16 + [DataRow(new byte[] { 0xEB, 0x10 })] // rel8 + [DataRow(new byte[] { 0xE9, 0x10, 0x00, 0x00, 0x00 })] // rel32 + [DataRow(new byte[] { 0x0F, 0x1F, 0x44, 0x00, 0x00 })] // multi-byte nop public void Decode_LengthMatrix_ExactAndNoFallback(byte[] code) { var insn = One(0x2000, code); - Assert.False(insn.IsFallback); - Assert.Equal(code.Length, insn.Length); + Assert.IsFalse(insn.IsFallback); + Assert.AreEqual(code.Length, insn.Length); } /// Verifies direct branches/calls compute the absolute target and the flow kind. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Decode_RelativeBranches_ResolveTargetAndFlow() { var call = One(0x1000, 0xE8, 0x00, 0x00, 0x00, 0x00); - Assert.Equal("call", call.Mnemonic); - Assert.Equal(0x1005UL, call.TargetAddress); - Assert.Equal(NativeFlowKind.Call, call.Flow); + Assert.AreEqual("call", call.Mnemonic); + Assert.AreEqual(0x1005UL, call.TargetAddress); + Assert.AreEqual(NativeFlowKind.Call, call.Flow); var je = One(0x1000, 0x0F, 0x84, 0x10, 0x00, 0x00, 0x00); - Assert.Equal(0x1016UL, je.TargetAddress); - Assert.Equal(NativeFlowKind.ConditionalBranch, je.Flow); + Assert.AreEqual(0x1016UL, je.TargetAddress); + Assert.AreEqual(NativeFlowKind.ConditionalBranch, je.Flow); var jmpIndirect = One(0x1000, 0xFF, 0xE0); // jmp rax - Assert.Equal("jmp", jmpIndirect.Mnemonic); - Assert.Equal(NativeFlowKind.IndirectJump, jmpIndirect.Flow); + Assert.AreEqual("jmp", jmpIndirect.Mnemonic); + Assert.AreEqual(NativeFlowKind.IndirectJump, jmpIndirect.Flow); } /// Verifies the enumerated runtime/system instructions decode to real mnemonics. - [Theory(Timeout = 30_000)] - [InlineData("nop", new byte[] { 0x90 })] - [InlineData("pause", new byte[] { 0xF3, 0x90 })] - [InlineData("int3", new byte[] { 0xCC })] - [InlineData("ud2", new byte[] { 0x0F, 0x0B })] - [InlineData("cpuid", new byte[] { 0x0F, 0xA2 })] - [InlineData("rdtsc", new byte[] { 0x0F, 0x31 })] - [InlineData("endbr64", new byte[] { 0xF3, 0x0F, 0x1E, 0xFA })] - [InlineData("endbr32", new byte[] { 0xF3, 0x0F, 0x1E, 0xFB })] - [InlineData("syscall", new byte[] { 0x0F, 0x05 })] - [InlineData("nop", new byte[] { 0x0F, 0x1F, 0x00 })] // nop dword ptr [rax] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow("nop", new byte[] { 0x90 })] + [DataRow("pause", new byte[] { 0xF3, 0x90 })] + [DataRow("int3", new byte[] { 0xCC })] + [DataRow("ud2", new byte[] { 0x0F, 0x0B })] + [DataRow("cpuid", new byte[] { 0x0F, 0xA2 })] + [DataRow("rdtsc", new byte[] { 0x0F, 0x31 })] + [DataRow("endbr64", new byte[] { 0xF3, 0x0F, 0x1E, 0xFA })] + [DataRow("endbr32", new byte[] { 0xF3, 0x0F, 0x1E, 0xFB })] + [DataRow("syscall", new byte[] { 0x0F, 0x05 })] + [DataRow("nop", new byte[] { 0x0F, 0x1F, 0x00 })] // nop dword ptr [rax] public void Decode_RuntimeSystem_RealMnemonics(string mnemonic, byte[] code) { var insn = One(0x1000, code); - Assert.Equal(mnemonic, insn.Mnemonic); - Assert.False(insn.IsFallback); - Assert.Equal(NativeInstructionCategory.System, insn.Category); - Assert.Equal(code.Length, insn.Length); + Assert.AreEqual(mnemonic, insn.Mnemonic); + Assert.IsFalse(insn.IsFallback); + Assert.AreEqual(NativeInstructionCategory.System, insn.Category); + Assert.AreEqual(code.Length, insn.Length); } /// @@ -114,7 +119,8 @@ public void Decode_RuntimeSystem_RealMnemonics(string mnemonic, byte[] code) /// canonical operand stream — the table-completeness invariant behind "exact length by /// construction". A gap would surface here rather than as a silent .byte on real code. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Decode_EveryDefinedOneByteOpcode_IsNotFallback() { Span buffer = stackalloc byte[16]; @@ -125,7 +131,7 @@ public void Decode_EveryDefinedOneByteOpcode_IsNotFallback() buffer.Clear(); buffer[0] = (byte)opcode; var insn = NativeDisassembler.Disassemble(buffer.ToArray(), 0x1000, NativeArchitecture.X64)[0]; - Assert.False(insn.IsFallback, $"opcode 0x{opcode:x2} fell back to .byte"); + Assert.IsFalse(insn.IsFallback, $"opcode 0x{opcode:x2} fell back to .byte"); } } @@ -133,7 +139,8 @@ public void Decode_EveryDefinedOneByteOpcode_IsNotFallback() /// Verifies the decoder never throws and always advances on arbitrary bytes — no desync, no /// infinite loop — so a corrupt or uncovered region degrades gracefully. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Disassemble_ArbitraryBytes_NeverThrowsAndAlwaysAdvances() { var code = new byte[4096]; @@ -141,22 +148,23 @@ public void Disassemble_ArbitraryBytes_NeverThrowsAndAlwaysAdvances() var insns = NativeDisassembler.Disassemble(code, 0x400000, NativeArchitecture.X64); - Assert.All(insns, i => Assert.True(i.Length >= 1)); + TestAssert.All(insns, i => Assert.IsGreaterThanOrEqualTo(1, i.Length)); // Every byte is consumed except at most one truncated instruction straddling the end. var consumed = insns.Sum(i => i.Length); - Assert.True(consumed <= code.Length); - Assert.True(code.Length - consumed < 16, $"desync: {code.Length - consumed} bytes unconsumed"); + Assert.IsLessThanOrEqualTo(code.Length, consumed); + Assert.IsLessThan(16, code.Length - consumed, $"desync: {code.Length - consumed} bytes unconsumed"); } /// Verifies an undefined opcode falls back to one byte and resynchronizes. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Decode_UndefinedOpcode_OneByteFallbackResyncs() { // 0x06 (push es) is #UD in 64-bit; the following 0x90 must decode as nop. var insns = NativeDisassembler.Disassemble([0x06, 0x90], 0x1000, NativeArchitecture.X64); - Assert.Equal(2, insns.Count); - Assert.True(insns[0].IsFallback); - Assert.Equal(1, insns[0].Length); - Assert.Equal("nop", insns[1].Mnemonic); + Assert.HasCount(2, insns); + Assert.IsTrue(insns[0].IsFallback); + Assert.AreEqual(1, insns[0].Length); + Assert.AreEqual("nop", insns[1].Mnemonic); } } diff --git a/tests/Dotsider.Tests/XarchSseTests.cs b/tests/Dotsider.Tests/XarchSseTests.cs index 6455bba6..5085d7c8 100644 --- a/tests/Dotsider.Tests/XarchSseTests.cs +++ b/tests/Dotsider.Tests/XarchSseTests.cs @@ -8,50 +8,53 @@ namespace Dotsider.Tests; /// memory size hints (scalar dword/qword vs packed xmmword), conversions, packed-integer ops, /// the 0F 38 / 0F 3A maps, and the shift-by-immediate groups — cross-checked against objdump. /// +[TestClass] public class XarchSseTests { private static NativeInstruction One(params byte[] code) => NativeDisassembler.Disassemble(code, 0x1000, NativeArchitecture.X64)[0]; /// Decodes representative SSE opcodes to their exact mnemonics, operands, and length. - [Theory(Timeout = 30_000)] - [InlineData("movaps", "xmm0, xmm1", new byte[] { 0x0F, 0x28, 0xC1 })] - [InlineData("movapd", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0x28, 0xC1 })] - [InlineData("movss", "xmm0, dword ptr [rax]", new byte[] { 0xF3, 0x0F, 0x10, 0x00 })] - [InlineData("movsd", "xmm0, qword ptr [rax]", new byte[] { 0xF2, 0x0F, 0x10, 0x00 })] - [InlineData("addps", "xmm0, xmm1", new byte[] { 0x0F, 0x58, 0xC1 })] - [InlineData("addsd", "xmm0, xmm1", new byte[] { 0xF2, 0x0F, 0x58, 0xC1 })] - [InlineData("mulss", "xmm0, xmm1", new byte[] { 0xF3, 0x0F, 0x59, 0xC1 })] - [InlineData("pxor", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0xEF, 0xC1 })] - [InlineData("paddd", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0xFE, 0xC1 })] - [InlineData("movdqu", "xmm0, xmmword ptr [rax]", new byte[] { 0xF3, 0x0F, 0x6F, 0x00 })] - [InlineData("movd", "xmm0, eax", new byte[] { 0x66, 0x0F, 0x6E, 0xC0 })] - [InlineData("movq", "xmm0, rax", new byte[] { 0x66, 0x48, 0x0F, 0x6E, 0xC0 })] - [InlineData("cvtsi2sd", "xmm0, eax", new byte[] { 0xF2, 0x0F, 0x2A, 0xC0 })] - [InlineData("cvttss2si", "eax, xmm1", new byte[] { 0xF3, 0x0F, 0x2C, 0xC1 })] - [InlineData("pshufd", "xmm0, xmm1, 0x1b", new byte[] { 0x66, 0x0F, 0x70, 0xC1, 0x1B })] - [InlineData("pslldq", "xmm1, 0x4", new byte[] { 0x66, 0x0F, 0x73, 0xF9, 0x04 })] - [InlineData("pshufb", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0x38, 0x00, 0xC1 })] - [InlineData("pcmpeqq", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0x38, 0x29, 0xC1 })] - [InlineData("pmulld", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0x38, 0x40, 0xC1 })] - [InlineData("roundsd", "xmm0, xmm1, 0x8", new byte[] { 0x66, 0x0F, 0x3A, 0x0B, 0xC1, 0x08 })] - [InlineData("crc32", "eax, ecx", new byte[] { 0xF2, 0x0F, 0x38, 0xF1, 0xC1 })] - [InlineData("xorps", "xmm0, xmm1", new byte[] { 0x0F, 0x57, 0xC1 })] - [InlineData("cmpps", "xmm0, xmm1, 0x0", new byte[] { 0x0F, 0xC2, 0xC1, 0x00 })] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] + [DataRow("movaps", "xmm0, xmm1", new byte[] { 0x0F, 0x28, 0xC1 })] + [DataRow("movapd", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0x28, 0xC1 })] + [DataRow("movss", "xmm0, dword ptr [rax]", new byte[] { 0xF3, 0x0F, 0x10, 0x00 })] + [DataRow("movsd", "xmm0, qword ptr [rax]", new byte[] { 0xF2, 0x0F, 0x10, 0x00 })] + [DataRow("addps", "xmm0, xmm1", new byte[] { 0x0F, 0x58, 0xC1 })] + [DataRow("addsd", "xmm0, xmm1", new byte[] { 0xF2, 0x0F, 0x58, 0xC1 })] + [DataRow("mulss", "xmm0, xmm1", new byte[] { 0xF3, 0x0F, 0x59, 0xC1 })] + [DataRow("pxor", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0xEF, 0xC1 })] + [DataRow("paddd", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0xFE, 0xC1 })] + [DataRow("movdqu", "xmm0, xmmword ptr [rax]", new byte[] { 0xF3, 0x0F, 0x6F, 0x00 })] + [DataRow("movd", "xmm0, eax", new byte[] { 0x66, 0x0F, 0x6E, 0xC0 })] + [DataRow("movq", "xmm0, rax", new byte[] { 0x66, 0x48, 0x0F, 0x6E, 0xC0 })] + [DataRow("cvtsi2sd", "xmm0, eax", new byte[] { 0xF2, 0x0F, 0x2A, 0xC0 })] + [DataRow("cvttss2si", "eax, xmm1", new byte[] { 0xF3, 0x0F, 0x2C, 0xC1 })] + [DataRow("pshufd", "xmm0, xmm1, 0x1b", new byte[] { 0x66, 0x0F, 0x70, 0xC1, 0x1B })] + [DataRow("pslldq", "xmm1, 0x4", new byte[] { 0x66, 0x0F, 0x73, 0xF9, 0x04 })] + [DataRow("pshufb", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0x38, 0x00, 0xC1 })] + [DataRow("pcmpeqq", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0x38, 0x29, 0xC1 })] + [DataRow("pmulld", "xmm0, xmm1", new byte[] { 0x66, 0x0F, 0x38, 0x40, 0xC1 })] + [DataRow("roundsd", "xmm0, xmm1, 0x8", new byte[] { 0x66, 0x0F, 0x3A, 0x0B, 0xC1, 0x08 })] + [DataRow("crc32", "eax, ecx", new byte[] { 0xF2, 0x0F, 0x38, 0xF1, 0xC1 })] + [DataRow("xorps", "xmm0, xmm1", new byte[] { 0x0F, 0x57, 0xC1 })] + [DataRow("cmpps", "xmm0, xmm1, 0x0", new byte[] { 0x0F, 0xC2, 0xC1, 0x00 })] public void Decode_Sse_MnemonicAndOperands(string mnemonic, string operands, byte[] code) { var insn = One(code); - Assert.False(insn.IsFallback); - Assert.Equal(mnemonic, insn.Mnemonic); - Assert.Equal(operands, insn.OperandText); - Assert.Equal(code.Length, insn.Length); + Assert.IsFalse(insn.IsFallback); + Assert.AreEqual(mnemonic, insn.Mnemonic); + Assert.AreEqual(operands, insn.OperandText); + Assert.AreEqual(code.Length, insn.Length); } /// Verifies packed ops classify as Vector and scalar single/double as Float. - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public void Decode_Sse_ClassifiesVectorAndFloat() { - Assert.Equal(NativeInstructionCategory.Vector, One(0x66, 0x0F, 0xEF, 0xC1).Category); // pxor - Assert.Equal(NativeInstructionCategory.Float, One(0xF2, 0x0F, 0x58, 0xC1).Category); // addsd + Assert.AreEqual(NativeInstructionCategory.Vector, One(0x66, 0x0F, 0xEF, 0xC1).Category); // pxor + Assert.AreEqual(NativeInstructionCategory.Float, One(0xF2, 0x0F, 0x58, 0xC1).Category); // addsd } } diff --git a/tests/Dotsider.Tests/YankHelperTests.cs b/tests/Dotsider.Tests/YankHelperTests.cs index 5e11da9a..49768c59 100644 --- a/tests/Dotsider.Tests/YankHelperTests.cs +++ b/tests/Dotsider.Tests/YankHelperTests.cs @@ -11,9 +11,11 @@ namespace Dotsider.Tests; /// /// Tests for Yank Helper. /// -[Collection("SampleAssemblies")] -public class YankHelperTests(SampleAssemblyFixture samples) : IDisposable +[TestClass] +public class YankHelperTests : IDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private readonly List _disposables = []; private DotsiderState CreateState(string dllPath) @@ -38,9 +40,9 @@ private DotsiderState CreateState(string dllPath) /// public void Dispose() { + GC.SuppressFinalize(this); foreach (var d in _disposables) d.Dispose(); - GC.SuppressFinalize(this); } // --- GetYankText(DotsiderState) --- @@ -48,17 +50,17 @@ public void Dispose() /// /// Verifies general focused ref returns tab separated row. /// - [Fact] + [TestMethod] public void General_FocusedRef_ReturnsTabSeparatedRow() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.General; var firstRef = state.Analyzer.AssemblyRefs[0]; state.GeneralFocusedDep = firstRef.Name; var text = YankHelper.GetYankText(state); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains(firstRef.Name, text); Assert.Contains(firstRef.Version, text); Assert.Contains("\t", text); @@ -67,23 +69,23 @@ public void General_FocusedRef_ReturnsTabSeparatedRow() /// /// Verifies general no focused ref returns null. /// - [Fact] + [TestMethod] public void General_NoFocusedRef_ReturnsNull() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.General; state.GeneralFocusedDep = null; - Assert.Null(YankHelper.GetYankText(state)); + Assert.IsNull(YankHelper.GetYankText(state)); } /// /// Verifies pe metadata sections returns formatted row. /// - [Fact] + [TestMethod] public void PeMetadata_Sections_ReturnsFormattedRow() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.PeMetadata; state.PeSubTab = PeSubTabId.Sections; var section = state.Analyzer.Sections[0]; @@ -91,7 +93,7 @@ public void PeMetadata_Sections_ReturnsFormattedRow() var text = YankHelper.GetYankText(state); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains(section.Name, text); Assert.Contains("0x", text); } @@ -99,10 +101,10 @@ public void PeMetadata_Sections_ReturnsFormattedRow() /// /// Verifies pe metadata type def returns formatted row. /// - [Fact] + [TestMethod] public void PeMetadata_TypeDef_ReturnsFormattedRow() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.PeMetadata; state.PeSubTab = PeSubTabId.TypeDef; var typeDef = state.Analyzer.TypeDefs.First(t => !t.FullName.StartsWith('<')); @@ -110,17 +112,17 @@ public void PeMetadata_TypeDef_ReturnsFormattedRow() var text = YankHelper.GetYankText(state); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains(typeDef.FullName, text); } /// /// Verifies pe metadata method def returns formatted row. /// - [Fact] + [TestMethod] public void PeMetadata_MethodDef_ReturnsFormattedRow() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.PeMetadata; state.PeSubTab = PeSubTabId.MethodDef; var method = state.Analyzer.MethodDefs.First(m => m.Rva > 0); @@ -128,7 +130,7 @@ public void PeMetadata_MethodDef_ReturnsFormattedRow() var text = YankHelper.GetYankText(state); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains(method.Name, text); Assert.Contains(method.DeclaringType, text); } @@ -136,10 +138,10 @@ public void PeMetadata_MethodDef_ReturnsFormattedRow() /// /// Verifies pe metadata type ref returns formatted row. /// - [Fact] + [TestMethod] public void PeMetadata_TypeRef_ReturnsFormattedRow() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.PeMetadata; state.PeSubTab = PeSubTabId.TypeRef; var typeRef = state.Analyzer.TypeRefs[0]; @@ -147,17 +149,17 @@ public void PeMetadata_TypeRef_ReturnsFormattedRow() var text = YankHelper.GetYankText(state); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains(typeRef.FullName, text); } /// /// Verifies pe metadata member ref returns formatted row. /// - [Fact] + [TestMethod] public void PeMetadata_MemberRef_ReturnsFormattedRow() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.PeMetadata; state.PeSubTab = PeSubTabId.MemberRef; var memberRef = state.Analyzer.MemberRefs[0]; @@ -165,17 +167,17 @@ public void PeMetadata_MemberRef_ReturnsFormattedRow() var text = YankHelper.GetYankText(state); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains(memberRef.Name, text); } /// /// Verifies pe metadata attributes returns formatted row. /// - [Fact] + [TestMethod] public void PeMetadata_Attributes_ReturnsFormattedRow() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.PeMetadata; state.PeSubTab = PeSubTabId.Attributes; var attr = state.Analyzer.CustomAttributes[0]; @@ -183,17 +185,17 @@ public void PeMetadata_Attributes_ReturnsFormattedRow() var text = YankHelper.GetYankText(state); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains(attr.Parent, text); } /// /// Verifies pe metadata resources returns formatted row. /// - [Fact] + [TestMethod] public void PeMetadata_Resources_ReturnsFormattedRow() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.PeMetadata; state.PeSubTab = PeSubTabId.Resources; if (state.Analyzer.Resources.Count == 0) return; // some assemblies have none @@ -202,61 +204,61 @@ public void PeMetadata_Resources_ReturnsFormattedRow() var text = YankHelper.GetYankText(state); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains(resource.Name, text); } /// /// Verifies pe metadata no focused key returns null. /// - [Fact] + [TestMethod] public void PeMetadata_NoFocusedKey_ReturnsNull() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.PeMetadata; state.PeFocusedKey = null; - Assert.Null(YankHelper.GetYankText(state)); + Assert.IsNull(YankHelper.GetYankText(state)); } /// /// Verifies strings focused entry returns string value. /// - [Fact] + [TestMethod] public void Strings_FocusedEntry_ReturnsStringValue() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.Strings; var strings = state.GetActiveStrings(); - Assert.True(strings.Count > 0); + Assert.IsGreaterThan(0, strings.Count); var entry = strings[0]; state.StringsFocusedKey = $"{entry.Offset}:{entry.Source}"; var text = YankHelper.GetYankText(state); - Assert.Equal(entry.Value, text); + Assert.AreEqual(entry.Value, text); } /// /// Verifies dep graph selected node returns node name. /// - [Fact] + [TestMethod] public void DepGraph_SelectedNode_ReturnsNodeName() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.DepGraph; state.GraphSelectedNode = "System.Runtime v10.0.0.0"; - Assert.Equal("System.Runtime v10.0.0.0", YankHelper.GetYankText(state)); + Assert.AreEqual("System.Runtime v10.0.0.0", YankHelper.GetYankText(state)); } /// /// Verifies dep graph selected index returns node name with version. /// - [Fact] + [TestMethod] public void DepGraph_SelectedIndex_ReturnsNodeNameWithVersion() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.DepGraph; if (state.CachedGraph is null) { @@ -265,12 +267,12 @@ public void DepGraph_SelectedIndex_ReturnsNodeNameWithVersion() state.GraphNavigation = result.NavigationById; } var nodes = state.CachedGraph!.Value.Nodes; - Assert.True(nodes.Count > 0); + Assert.IsGreaterThan(0, nodes.Count); state.GraphSelectedIndex = 0; var text = YankHelper.GetYankText(state); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains(nodes[0].Name, text); } @@ -280,10 +282,10 @@ public void DepGraph_SelectedIndex_ReturnsNodeNameWithVersion() /// underlying cached graph. The selected-index-to-node mapping must follow the same /// visible projection the view uses. /// - [Fact] + [TestMethod] public void DepGraph_Yank_UsesVisibleModel_UnderDirectOnlyScope() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.DepGraph; var result = DependencyGraphBuilder.Build(state.Analyzer); state.CachedGraph = (result.Nodes, result.Edges); @@ -294,153 +296,152 @@ public void DepGraph_Yank_UsesVisibleModel_UnderDirectOnlyScope() var visible = Views.DependencyGraphView.BuildVisibleModel( result.Nodes, result.Edges, result.NavigationById, state.DepGraphScope, state.DepGraphHideFramework); - Assert.True(visible.Nodes.Count >= 2, - "RichLibrary should have root plus at least one direct ref"); + Assert.IsGreaterThanOrEqualTo(2, visible.Nodes.Count, "RichLibrary should have root plus at least one direct ref"); state.GraphSelectedIndex = 1; var text = YankHelper.GetYankText(state); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains(visible.Nodes[1].Name, text); } /// /// Verifies size map selected index returns item with size. /// - [Fact] + [TestMethod] public void SizeMap_SelectedIndex_ReturnsItemWithSize() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.SizeMap; state.CachedSizeTree ??= SizeAnalyzer.BuildSizeTree(state.Analyzer); var level = state.CachedSizeTree; - Assert.True(level.Children.Count > 0); + Assert.IsGreaterThan(0, level.Children.Count); state.TreemapSelectedIndex = 0; var text = YankHelper.GetYankText(state); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains(level.Children[0].FullPath, text); } /// /// Verifies size map hovered item returns hovered text. /// - [Fact] + [TestMethod] public void SizeMap_HoveredItem_ReturnsHoveredText() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.SizeMap; state.TreemapHoveredItem = " RichLibrary.Models: 1.2 KB "; var text = YankHelper.GetYankText(state); - Assert.Equal("RichLibrary.Models: 1.2 KB", text); // Trimmed + Assert.AreEqual("RichLibrary.Models: 1.2 KB", text); // Trimmed } /// /// Verifies size map no selection returns null. /// - [Fact] + [TestMethod] public void SizeMap_NoSelection_ReturnsNull() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.SizeMap; state.TreemapSelectedIndex = -1; state.TreemapHoveredItem = null; - Assert.Null(YankHelper.GetYankText(state)); + Assert.IsNull(YankHelper.GetYankText(state)); } /// /// Verifies dynamic no tracer returns null. /// - [Fact] + [TestMethod] public void Dynamic_NoTracer_ReturnsNull() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.Dynamic; state.Tracer = null; - Assert.Null(YankHelper.GetYankText(state)); + Assert.IsNull(YankHelper.GetYankText(state)); } /// /// Verifies dynamic events no focused key returns null. /// - [Fact] + [TestMethod] public void Dynamic_Events_NoFocusedKey_ReturnsNull() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.Dynamic; state.DynamicSubTab = DynamicSubTabId.Events; state.DynamicEventsFocusedKey = null; - Assert.Null(YankHelper.GetYankText(state)); + Assert.IsNull(YankHelper.GetYankText(state)); } /// /// Verifies dynamic output no focused key returns null. /// - [Fact] + [TestMethod] public void Dynamic_Output_NoFocusedKey_ReturnsNull() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.Dynamic; state.DynamicSubTab = DynamicSubTabId.Output; state.DynamicOutputFocusedKey = null; - Assert.Null(YankHelper.GetYankText(state)); + Assert.IsNull(YankHelper.GetYankText(state)); } /// /// Verifies dynamic counters returns null. /// - [Fact] + [TestMethod] public void Dynamic_Counters_ReturnsNull() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.Dynamic; state.DynamicSubTab = DynamicSubTabId.Counters; - Assert.Null(YankHelper.GetYankText(state)); + Assert.IsNull(YankHelper.GetYankText(state)); } /// /// Verifies dynamic summary returns null. /// - [Fact] + [TestMethod] public void Dynamic_Summary_ReturnsNull() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.Dynamic; state.DynamicSubTab = DynamicSubTabId.Summary; - Assert.Null(YankHelper.GetYankText(state)); + Assert.IsNull(YankHelper.GetYankText(state)); } /// /// Verifies il inspector returns null. /// - [Fact] + [TestMethod] public void IlInspector_ReturnsNull() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.IlInspector; - Assert.Null(YankHelper.GetYankText(state)); + Assert.IsNull(YankHelper.GetYankText(state)); } /// /// Verifies hex dump returns null. /// - [Fact] + [TestMethod] public void HexDump_ReturnsNull() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); state.CurrentTab = TabId.HexDump; - Assert.Null(YankHelper.GetYankText(state)); + Assert.IsNull(YankHelper.GetYankText(state)); } // --- GetYankText(DiffState) --- @@ -448,14 +449,14 @@ public void HexDump_ReturnsNull() /// /// Verifies diff types focused row returns formatted text. /// - [Fact] + [TestMethod] public void Diff_Types_FocusedRow_ReturnsFormattedText() { var workload = new Hex1bAppWorkloadAdapter(); var terminal = Hex1bTerminal.CreateBuilder().WithWorkload(workload).WithHeadless().WithDimensions(80, 24).Build(); var app = new Hex1bApp(_ => Task.FromResult(new TextBlockWidget("")), new Hex1bAppOptions { WorkloadAdapter = workload }); - using var state = new DiffState(app, samples.RichLibraryDll, samples.RichLibraryV2Dll); + using var state = new DiffState(app, Samples.RichLibraryDll, Samples.RichLibraryV2Dll); state.CurrentTab = 1; // Types var entry = state.DiffResult.TypeDiffs.First(e => e.Kind != DiffKind.Unchanged); var type = entry.Right ?? entry.Left!; @@ -463,7 +464,7 @@ public void Diff_Types_FocusedRow_ReturnsFormattedText() var text = YankHelper.GetYankText(state); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains(type.FullName, text); Assert.Contains(entry.Kind.ToString(), text); } @@ -471,14 +472,14 @@ public void Diff_Types_FocusedRow_ReturnsFormattedText() /// /// Verifies diff methods focused row returns formatted text. /// - [Fact] + [TestMethod] public void Diff_Methods_FocusedRow_ReturnsFormattedText() { var workload = new Hex1bAppWorkloadAdapter(); var terminal = Hex1bTerminal.CreateBuilder().WithWorkload(workload).WithHeadless().WithDimensions(80, 24).Build(); var app = new Hex1bApp(_ => Task.FromResult(new TextBlockWidget("")), new Hex1bAppOptions { WorkloadAdapter = workload }); - using var state = new DiffState(app, samples.RichLibraryDll, samples.RichLibraryV2Dll); + using var state = new DiffState(app, Samples.RichLibraryDll, Samples.RichLibraryV2Dll); state.CurrentTab = 2; // Methods var entry = state.DiffResult.MethodDiffs.First(e => e.Kind != DiffKind.Unchanged); var method = entry.Right ?? entry.Left!; @@ -486,21 +487,21 @@ public void Diff_Methods_FocusedRow_ReturnsFormattedText() var text = YankHelper.GetYankText(state); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains(method.Name, text); } /// /// Verifies diff refs focused row returns formatted text. /// - [Fact] + [TestMethod] public void Diff_Refs_FocusedRow_ReturnsFormattedText() { var workload = new Hex1bAppWorkloadAdapter(); var terminal = Hex1bTerminal.CreateBuilder().WithWorkload(workload).WithHeadless().WithDimensions(80, 24).Build(); var app = new Hex1bApp(_ => Task.FromResult(new TextBlockWidget("")), new Hex1bAppOptions { WorkloadAdapter = workload }); - using var state = new DiffState(app, samples.RichLibraryDll, samples.RichLibraryV2Dll); + using var state = new DiffState(app, Samples.RichLibraryDll, Samples.RichLibraryV2Dll); state.CurrentTab = 3; // Refs var entry = state.DiffResult.AssemblyRefDiffs.First(e => e.Kind != DiffKind.Unchanged); var name = entry.Right?.Name ?? entry.Left?.Name ?? ""; @@ -508,41 +509,41 @@ public void Diff_Refs_FocusedRow_ReturnsFormattedText() var text = YankHelper.GetYankText(state); - Assert.NotNull(text); + Assert.IsNotNull(text); Assert.Contains(name, text); } /// /// Verifies diff summary returns null. /// - [Fact] + [TestMethod] public void Diff_Summary_ReturnsNull() { var workload = new Hex1bAppWorkloadAdapter(); var terminal = Hex1bTerminal.CreateBuilder().WithWorkload(workload).WithHeadless().WithDimensions(80, 24).Build(); var app = new Hex1bApp(_ => Task.FromResult(new TextBlockWidget("")), new Hex1bAppOptions { WorkloadAdapter = workload }); - using var state = new DiffState(app, samples.RichLibraryDll, samples.RichLibraryV2Dll); + using var state = new DiffState(app, Samples.RichLibraryDll, Samples.RichLibraryV2Dll); state.CurrentTab = 0; // Summary - Assert.Null(YankHelper.GetYankText(state)); + Assert.IsNull(YankHelper.GetYankText(state)); } /// /// Verifies diff no focused key returns null. /// - [Fact] + [TestMethod] public void Diff_NoFocusedKey_ReturnsNull() { var workload = new Hex1bAppWorkloadAdapter(); var terminal = Hex1bTerminal.CreateBuilder().WithWorkload(workload).WithHeadless().WithDimensions(80, 24).Build(); var app = new Hex1bApp(_ => Task.FromResult(new TextBlockWidget("")), new Hex1bAppOptions { WorkloadAdapter = workload }); - using var state = new DiffState(app, samples.RichLibraryDll, samples.RichLibraryV2Dll); + using var state = new DiffState(app, Samples.RichLibraryDll, Samples.RichLibraryV2Dll); state.CurrentTab = 1; state.DiffFocusedKey = null; - Assert.Null(YankHelper.GetYankText(state)); + Assert.IsNull(YankHelper.GetYankText(state)); } // --- GetHexSelectionText --- @@ -550,10 +551,10 @@ public void Diff_NoFocusedKey_ReturnsNull() /// /// Verifies get hex selection text returns uppercase space separated hex. /// - [Fact] + [TestMethod] public void GetHexSelectionText_ReturnsUppercaseSpaceSeparatedHex() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); var hexState = state.HexEditorState; var byteMap = hexState.Document.GetByteMap(); @@ -565,27 +566,27 @@ public void GetHexSelectionText_ReturnsUppercaseSpaceSeparatedHex() var result = YankHelper.GetHexSelectionText(hexState); - Assert.NotNull(result); + Assert.IsNotNull(result); // PE files start with MZ (4D 5A) Assert.StartsWith("4D 5A", result); // Verify format: uppercase, space-separated var parts = result.Split(' '); - Assert.True(parts.Length >= 4); + Assert.IsGreaterThanOrEqualTo(4, parts.Length); foreach (var part in parts) { - Assert.Equal(2, part.Length); - Assert.True(part.All(c => char.IsDigit(c) || (c >= 'A' && c <= 'F'))); + Assert.HasCount(2, part); + Assert.IsTrue(part.All(c => char.IsDigit(c) || (c >= 'A' && c <= 'F'))); } } /// /// Verifies get hex selection text no selection returns null. /// - [Fact] + [TestMethod] public void GetHexSelectionText_NoSelection_ReturnsNull() { - using var state = CreateState(samples.RichLibraryDll); - Assert.Null(YankHelper.GetHexSelectionText(state.HexEditorState)); + using var state = CreateState(Samples.RichLibraryDll); + Assert.IsNull(YankHelper.GetHexSelectionText(state.HexEditorState)); } // --- FindYankProvider --- @@ -593,80 +594,80 @@ public void GetHexSelectionText_NoSelection_ReturnsNull() /// /// Verifies find yank provider dotsider state matches all editors. /// - [Fact] + [TestMethod] public void FindYankProvider_DotsiderState_MatchesAllEditors() { - using var state = CreateState(samples.RichLibraryDll); + using var state = CreateState(Samples.RichLibraryDll); // IL editor state is null until a method is selected — create one for testing state.IlEditorState = new EditorState(new Hex1bDocument("il test")) { IsReadOnly = true }; - Assert.Same(state.IlYankProvider, YankHelper.FindYankProvider(state, state.IlEditorState)); + Assert.AreSame(state.IlYankProvider, YankHelper.FindYankProvider(state, state.IlEditorState)); state.GeneralInfoEditorState = new EditorState(new Hex1bDocument("test")) { IsReadOnly = true }; - Assert.Same(state.GeneralInfoYankProvider, YankHelper.FindYankProvider(state, state.GeneralInfoEditorState)); + Assert.AreSame(state.GeneralInfoYankProvider, YankHelper.FindYankProvider(state, state.GeneralInfoEditorState)); state.PeHeadersEditorState = new EditorState(new Hex1bDocument("test")) { IsReadOnly = true }; - Assert.Same(state.PeHeadersYankProvider, YankHelper.FindYankProvider(state, state.PeHeadersEditorState)); + Assert.AreSame(state.PeHeadersYankProvider, YankHelper.FindYankProvider(state, state.PeHeadersEditorState)); state.ClrHeaderEditorState = new EditorState(new Hex1bDocument("test")) { IsReadOnly = true }; - Assert.Same(state.ClrHeaderYankProvider, YankHelper.FindYankProvider(state, state.ClrHeaderEditorState)); + Assert.AreSame(state.ClrHeaderYankProvider, YankHelper.FindYankProvider(state, state.ClrHeaderEditorState)); state.PeDetailEditorState = new EditorState(new Hex1bDocument("test")) { IsReadOnly = true }; - Assert.Same(state.PeDetailYankProvider, YankHelper.FindYankProvider(state, state.PeDetailEditorState)); + Assert.AreSame(state.PeDetailYankProvider, YankHelper.FindYankProvider(state, state.PeDetailEditorState)); state.StringsDetailEditorState = new EditorState(new Hex1bDocument("test")) { IsReadOnly = true }; - Assert.Same(state.StringsDetailYankProvider, YankHelper.FindYankProvider(state, state.StringsDetailEditorState)); + Assert.AreSame(state.StringsDetailYankProvider, YankHelper.FindYankProvider(state, state.StringsDetailEditorState)); state.DynamicCpuEditorState = new EditorState(new Hex1bDocument("test")) { IsReadOnly = true }; - Assert.Same(state.DynamicCpuYankProvider, YankHelper.FindYankProvider(state, state.DynamicCpuEditorState)); + Assert.AreSame(state.DynamicCpuYankProvider, YankHelper.FindYankProvider(state, state.DynamicCpuEditorState)); state.DynamicMemoryEditorState = new EditorState(new Hex1bDocument("test")) { IsReadOnly = true }; - Assert.Same(state.DynamicMemoryYankProvider, YankHelper.FindYankProvider(state, state.DynamicMemoryEditorState)); + Assert.AreSame(state.DynamicMemoryYankProvider, YankHelper.FindYankProvider(state, state.DynamicMemoryEditorState)); state.DynamicGcEditorState = new EditorState(new Hex1bDocument("test")) { IsReadOnly = true }; - Assert.Same(state.DynamicGcYankProvider, YankHelper.FindYankProvider(state, state.DynamicGcEditorState)); + Assert.AreSame(state.DynamicGcYankProvider, YankHelper.FindYankProvider(state, state.DynamicGcEditorState)); state.DynamicThreadingEditorState = new EditorState(new Hex1bDocument("test")) { IsReadOnly = true }; - Assert.Same(state.DynamicThreadingYankProvider, YankHelper.FindYankProvider(state, state.DynamicThreadingEditorState)); + Assert.AreSame(state.DynamicThreadingYankProvider, YankHelper.FindYankProvider(state, state.DynamicThreadingEditorState)); state.DynamicSummaryEditorState = new EditorState(new Hex1bDocument("test")) { IsReadOnly = true }; - Assert.Same(state.DynamicSummaryYankProvider, YankHelper.FindYankProvider(state, state.DynamicSummaryEditorState)); + Assert.AreSame(state.DynamicSummaryYankProvider, YankHelper.FindYankProvider(state, state.DynamicSummaryEditorState)); state.DataInterpEditorState = new EditorState(new Hex1bDocument("test")) { IsReadOnly = true }; - Assert.Same(state.DataInterpYankProvider, YankHelper.FindYankProvider(state, state.DataInterpEditorState)); + Assert.AreSame(state.DataInterpYankProvider, YankHelper.FindYankProvider(state, state.DataInterpEditorState)); // Unknown editor returns null var unknownState = new EditorState(new Hex1bDocument("unknown")) { IsReadOnly = true }; - Assert.Null(YankHelper.FindYankProvider(state, unknownState)); + Assert.IsNull(YankHelper.FindYankProvider(state, unknownState)); } /// /// Verifies find yank provider nu get state matches package info and delegates. /// - [Fact] + [TestMethod] public void FindYankProvider_NuGetState_MatchesPackageInfoAndDelegates() { var workload = new Hex1bAppWorkloadAdapter(); var terminal = Hex1bTerminal.CreateBuilder().WithWorkload(workload).WithHeadless().WithDimensions(80, 24).Build(); var app = new Hex1bApp(_ => Task.FromResult(new TextBlockWidget("")), new Hex1bAppOptions { WorkloadAdapter = workload }); - using var nugetState = new NuGetState(app, samples.RichLibraryNupkg); + using var nugetState = new NuGetState(app, Samples.RichLibraryNupkg); nugetState.PackageInfoEditorState = new EditorState(new Hex1bDocument("test")) { IsReadOnly = true }; - Assert.Same(nugetState.PackageInfoYankProvider, + Assert.AreSame(nugetState.PackageInfoYankProvider, YankHelper.FindYankProvider(nugetState, nugetState.PackageInfoEditorState)); // With a selected DLL state, delegates to DotsiderState lookup - nugetState.SelectedDllState = new DotsiderState(app, samples.RichLibraryDll) + nugetState.SelectedDllState = new DotsiderState(app, Samples.RichLibraryDll) { GeneralInfoEditorState = new EditorState(new Hex1bDocument("test")) { IsReadOnly = true } }; - Assert.Same(nugetState.SelectedDllState.GeneralInfoYankProvider, + Assert.AreSame(nugetState.SelectedDllState.GeneralInfoYankProvider, YankHelper.FindYankProvider(nugetState, nugetState.SelectedDllState.GeneralInfoEditorState)); // Unknown returns null var unknown = new EditorState(new Hex1bDocument("x")) { IsReadOnly = true }; - Assert.Null(YankHelper.FindYankProvider(nugetState, unknown)); + Assert.IsNull(YankHelper.FindYankProvider(nugetState, unknown)); } // --- CursorColorHelper --- @@ -674,7 +675,7 @@ public void FindYankProvider_NuGetState_MatchesPackageInfoAndDelegates() /// /// Verifies cursor color helper set sequence is osc12. /// - [Fact] + [TestMethod] public void CursorColorHelper_SetSequence_IsOsc12() { Assert.StartsWith("\x1b]12;", CursorColorHelper.SetTealSequence); @@ -682,53 +683,25 @@ public void CursorColorHelper_SetSequence_IsOsc12() Assert.EndsWith("\x1b\\", CursorColorHelper.SetTealSequence); } - /// - /// Verifies cursor color helper reset sequence is osc112. - /// - [Fact] - public void CursorColorHelper_ResetSequence_IsOsc112() - { - Assert.Equal("\x1b]112\x1b\\", CursorColorHelper.ResetSequence); - } - /// /// Verifies cursor color helper reset cursor color writes to console. /// - [Fact] + [TestMethod] public void CursorColorHelper_ResetCursorColor_WritesToConsole() { - // Capture console output - var original = Console.Out; using var sw = new StringWriter(); - Console.SetOut(sw); - try - { - CursorColorHelper.ResetCursorColor(); - Assert.Equal(CursorColorHelper.ResetSequence, sw.ToString()); - } - finally - { - Console.SetOut(original); - } + CursorColorHelper.ResetCursorColor(sw); + Assert.AreEqual(CursorColorHelper.ResetSequence, sw.ToString()); } /// /// Verifies cursor color helper set theme cursor color writes to console. /// - [Fact] + [TestMethod] public void CursorColorHelper_SetThemeCursorColor_WritesToConsole() { - var original = Console.Out; using var sw = new StringWriter(); - Console.SetOut(sw); - try - { - CursorColorHelper.SetThemeCursorColor(); - Assert.Equal(CursorColorHelper.SetTealSequence, sw.ToString()); - } - finally - { - Console.SetOut(original); - } + CursorColorHelper.SetThemeCursorColor(sw); + Assert.AreEqual(CursorColorHelper.SetTealSequence, sw.ToString()); } } diff --git a/tests/Dotsider.Website.Tests/BundleResolutionRegressionTests.cs b/tests/Dotsider.Website.Tests/BundleResolutionRegressionTests.cs index 308bafbf..66af90fa 100644 --- a/tests/Dotsider.Website.Tests/BundleResolutionRegressionTests.cs +++ b/tests/Dotsider.Website.Tests/BundleResolutionRegressionTests.cs @@ -10,9 +10,11 @@ namespace Dotsider.Website.Tests; /// Reproduces the deployed scenario where RuntimeEnvironment.GetRuntimeDirectory() /// returns the app directory with no loose BCL files. /// -[Collection("SampleAssemblies")] -public sealed class BundleResolutionRegressionTests(SampleAssemblyFixture samples) : IAsyncDisposable +[TestClass] +public sealed class BundleResolutionRegressionTests : IAsyncDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private Process? _serverProcess; private ClientWebSocket? _ws; @@ -30,8 +32,8 @@ private async Task StartWebsiteAsync(CancellationToken ct) { StartInfo = new ProcessStartInfo { - FileName = samples.WebsitePublishedExe, - WorkingDirectory = samples.WebsitePublishedDir, + FileName = Samples.WebsitePublishedExe, + WorkingDirectory = Samples.WebsitePublishedDir, UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, @@ -41,13 +43,14 @@ private async Task StartWebsiteAsync(CancellationToken ct) ["DOTNET_ENVIRONMENT"] = "Production", // Point at the published sample payload (RichLibrary.dll sits alongside // its .deps.json and Newtonsoft.Json.dll), exactly as systemd does in prod. - ["Demo__SampleAssembly"] = samples.RichLibraryDll, + ["Demo__SampleAssembly"] = Samples.RichLibraryDll, ["Demo__MaxSessions"] = "5", ["Demo__SessionTimeoutMinutes"] = "1", ["Demo__AllowedOrigins__0"] = "*" } } }; + TestProcessEnvironment.RemoveCodeCoverageVariables(_serverProcess.StartInfo); _serverProcess.Start(); @@ -73,10 +76,11 @@ private async Task StartWebsiteAsync(CancellationToken ct) /// Launches the published single-file Website, connects via WebSocket, /// and drills down into a System assembly reference. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task DrillDown_Succeeds_InSingleFileHost() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var port = await StartWebsiteAsync(ct); _ws = new ClientWebSocket(); @@ -103,10 +107,11 @@ public async Task DrillDown_Succeeds_InSingleFileHost() /// works /// inside the single-file host. /// - [Fact(Timeout = 30_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task GoToDef_CallExternal_NavigatesToSystemConsole_InSingleFileHost() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var port = await StartWebsiteAsync(ct); _ws = new ClientWebSocket(); @@ -167,10 +172,11 @@ public async Task GoToDef_CallExternal_NavigatesToSystemConsole_InSingleFileHost /// full published payload (RichLibrary.dll + RichLibrary.deps.json /// + Newtonsoft.Json.dll, etc.) alongside the website. /// - [Fact(Timeout = 60_000)] + [TestMethod] + [Timeout(30_000, CooperativeCancellation = true)] public async Task DepGraph_NewtonsoftResolvesFromPublishedSample() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; var port = await StartWebsiteAsync(ct); _ws = new ClientWebSocket(); @@ -248,7 +254,6 @@ private static async Task WaitForOutputAsync( /// public async ValueTask DisposeAsync() { - GC.SuppressFinalize(this); if (_serverProcess is not null) { diff --git a/tests/Dotsider.Website.Tests/Dotsider.Website.Tests.csproj b/tests/Dotsider.Website.Tests/Dotsider.Website.Tests.csproj index ce8295e7..c794933f 100644 --- a/tests/Dotsider.Website.Tests/Dotsider.Website.Tests.csproj +++ b/tests/Dotsider.Website.Tests/Dotsider.Website.Tests.csproj @@ -1,30 +1,23 @@ - - - - net10.0 - enable - enable - true - true - + - - - + + net10.0 + enable + enable + true + true + - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - + + + + + + + + + + + - - - - diff --git a/tests/Dotsider.Website.Tests/SampleAssemblyCollection.cs b/tests/Dotsider.Website.Tests/SampleAssemblyCollection.cs deleted file mode 100644 index 2eed1bad..00000000 --- a/tests/Dotsider.Website.Tests/SampleAssemblyCollection.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace Dotsider.Website.Tests; - -/// -/// xUnit collection binding that shares a single SampleAssemblyFixture across website test classes. -/// -[CollectionDefinition("SampleAssemblies")] -public class SampleAssemblyCollection : ICollectionFixture; diff --git a/tests/Dotsider.Website.Tests/SampleAssemblyFixture.cs b/tests/Dotsider.Website.Tests/SampleAssemblyFixture.cs index e371f351..497b0ed8 100644 --- a/tests/Dotsider.Website.Tests/SampleAssemblyFixture.cs +++ b/tests/Dotsider.Website.Tests/SampleAssemblyFixture.cs @@ -4,9 +4,9 @@ namespace Dotsider.Website.Tests; /// -/// Shared xUnit fixture that builds and exposes sample assemblies used across website tests. +/// Shared MSTest fixture that builds and exposes sample assemblies used across website tests. /// -public class SampleAssemblyFixture : IAsyncLifetime +internal class SampleAssemblyFixture : IAsyncDisposable { private string _repoRoot = null!; @@ -44,8 +44,8 @@ public async ValueTask InitializeAsync() await PublishWebsite(); await PublishSample(); - Assert.True(File.Exists(RichLibraryDll), $"RichLibrary.dll not found at {RichLibraryDll}"); - Assert.True(File.Exists(WebsitePublishedExe), $"Website not found at {WebsitePublishedExe}"); + Assert.IsTrue(File.Exists(RichLibraryDll), $"RichLibrary.dll not found at {RichLibraryDll}"); + Assert.IsTrue(File.Exists(WebsitePublishedExe), $"Website not found at {WebsitePublishedExe}"); } /// @@ -87,6 +87,7 @@ private async Task PublishWebsite() WorkingDirectory = _repoRoot, UseShellExecute = false, }; + TestProcessEnvironment.RemoveCodeCoverageVariables(psi); var process = Process.Start(psi)!; await process.WaitForExitAsync(); @@ -143,6 +144,7 @@ private async Task PublishSample() RedirectStandardOutput = true, RedirectStandardError = true, }; + TestProcessEnvironment.RemoveCodeCoverageVariables(psi); var process = Process.Start(psi)!; var stdout = await process.StandardOutput.ReadToEndAsync(); diff --git a/tests/Dotsider.Website.Tests/SampleAssemblyHost.cs b/tests/Dotsider.Website.Tests/SampleAssemblyHost.cs new file mode 100644 index 00000000..24bd5f54 --- /dev/null +++ b/tests/Dotsider.Website.Tests/SampleAssemblyHost.cs @@ -0,0 +1,31 @@ +namespace Dotsider.Website.Tests; + +/// +/// MSTest assembly fixture that builds shared sample assemblies once for this test assembly. +/// +[TestClass] +public static class SampleAssemblyHost +{ + internal static SampleAssemblyFixture Instance { get; private set; } = null!; + + /// + /// Initializes shared sample assemblies before the test assembly runs. + /// + /// The MSTest assembly initialization context. + [AssemblyInitialize] + public static async Task AssemblyInitialize(TestContext context) + { + Instance = new SampleAssemblyFixture(); + await Instance.InitializeAsync(); + } + + /// + /// Cleans up shared sample assemblies after the test assembly completes. + /// + [AssemblyCleanup] + public static async Task AssemblyCleanup() + { + if (Instance is not null) + await Instance.DisposeAsync(); + } +} diff --git a/tests/Dotsider.Website.Tests/SearchInputTests.cs b/tests/Dotsider.Website.Tests/SearchInputTests.cs index 0a71b23b..9be482d5 100644 --- a/tests/Dotsider.Website.Tests/SearchInputTests.cs +++ b/tests/Dotsider.Website.Tests/SearchInputTests.cs @@ -12,9 +12,11 @@ namespace Dotsider.Website.Tests; /// Uses a real WebSocket pair to exercise the same code path as the browser /// (WebSocketPresentationAdapter.ReadInputAsync), not the headless path. /// -[Collection("SampleAssemblies")] -public class SearchInputTests(SampleAssemblyFixture samples) : IAsyncDisposable +[TestClass] +public class SearchInputTests : IAsyncDisposable { + private static SampleAssemblyFixture Samples => SampleAssemblyHost.Instance; + private WebSocket? _serverWs; private WebSocket? _clientWs; private Socket? _serverSocket; @@ -57,10 +59,11 @@ public class SearchInputTests(SampleAssemblyFixture samples) : IAsyncDisposable /// search bar and the subsequent characters reach the TextBox. This requires /// DotsiderApp to be reused across renders so _initialFocusRequested isn't reset. /// - [Fact(Timeout = 15_000)] + [TestMethod] + [Timeout(15_000, CooperativeCancellation = true)] public async Task SearchInput_ViaWebSocket_CharactersReachSearchBar() { - var ct = TestContext.Current.CancellationToken; + var ct = CancellationToken.None; // 1. Create WebSocket pair var (clientWs, _) = await CreateWebSocketPairAsync(); @@ -80,7 +83,7 @@ public async Task SearchInput_ViaWebSocket_CharactersReachSearchBar() _hex1bApp = new Hex1bApp( ctx => { - _state ??= new DotsiderState(_hex1bApp!, samples.RichLibraryDll); + _state ??= new DotsiderState(_hex1bApp!, Samples.RichLibraryDll); dotsiderApp ??= new DotsiderApp(_state); return Task.FromResult(dotsiderApp.Build(ctx)); }, @@ -126,9 +129,9 @@ await WaitForConditionAsync(() => _state?.Search[0].Query == "test", TimeSpan.FromSeconds(3), ct); // 7. Assert — characters should reach the TextBox - Assert.NotNull(_state); - Assert.True(_state.Search[0].IsActive, "Search should be active after pressing '/'"); - Assert.Equal("test", _state.Search[0].Query); + Assert.IsNotNull(_state); + Assert.IsTrue(_state.Search[0].IsActive, "Search should be active after pressing '/'"); + Assert.AreEqual("test", _state.Search[0].Query); // Cleanup: cancel app, stop drain, then tear down in DisposeAsync _appCts.Cancel(); diff --git a/tests/Shared/MSTestSettings.cs b/tests/Shared/MSTestSettings.cs new file mode 100644 index 00000000..6b352708 --- /dev/null +++ b/tests/Shared/MSTestSettings.cs @@ -0,0 +1 @@ +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] \ No newline at end of file diff --git a/tests/Shared/TestAssert.cs b/tests/Shared/TestAssert.cs new file mode 100644 index 00000000..40ffa1a2 --- /dev/null +++ b/tests/Shared/TestAssert.cs @@ -0,0 +1,37 @@ +/// +/// Provides shared assertion helpers used by MSTest projects. +/// The helpers preserve useful xUnit-style diagnostics where MSTest has no direct equivalent. +/// +internal static class TestAssert +{ + /// + /// Applies an assertion to every value and prefixes failures with the item index. + /// This mirrors the useful diagnostic shape of xUnit's collection assertions. + /// + /// The item type. + /// The values to inspect. + /// The assertion to run for each value. + public static void All(IEnumerable values, Action assertion) + { + var index = 0; + foreach (var value in values) + { + string? failureMessage = null; + Exception? failure = null; + try + { + assertion(value); + } + catch (Exception ex) when (ex is AssertFailedException or AssertInconclusiveException) + { + failureMessage = $"Item {index}: {ex.Message}"; + failure = ex; + } + + if (failureMessage is not null) + throw new AssertFailedException(failureMessage, failure!); + + index++; + } + } +} diff --git a/tests/Shared/TestProcessEnvironment.cs b/tests/Shared/TestProcessEnvironment.cs new file mode 100644 index 00000000..d4e03dc8 --- /dev/null +++ b/tests/Shared/TestProcessEnvironment.cs @@ -0,0 +1,78 @@ +using System.Collections; +using System.Diagnostics; + +/// +/// Helpers for launching child processes from tests without inheriting the code-coverage profiler +/// environment injected into the test host. +/// +internal static class TestProcessEnvironment +{ + // Matches the environment variables stripped by MSTest's own child-process test helpers. + private static readonly HashSet CodeCoverageEnvironmentVariables = new(StringComparer.OrdinalIgnoreCase) + { + "MicrosoftInstrumentationEngine_ConfigPath32_VanguardInstrumentationProfiler", + "MicrosoftInstrumentationEngine_ConfigPath64_VanguardInstrumentationProfiler", + "CORECLR_PROFILER_PATH_32", + "CORECLR_PROFILER_PATH_64", + "CORECLR_ENABLE_PROFILING", + "CORECLR_PROFILER", + "COR_PROFILER_PATH_32", + "COR_PROFILER_PATH_64", + "COR_ENABLE_PROFILING", + "COR_PROFILER", + "CODE_COVERAGE_SESSION_NAME", + "CODE_COVERAGE_PIPE_PATH", + "MicrosoftInstrumentationEngine_LogLevel", + "MicrosoftInstrumentationEngine_DisableCodeSignatureValidation", + "MicrosoftInstrumentationEngine_FileLogPath", + }; + + /// + /// Removes inherited code-coverage profiler variables from . + /// + /// The child process start info to sanitize. + internal static void RemoveCodeCoverageVariables(ProcessStartInfo startInfo) + { + foreach (var variable in CodeCoverageEnvironmentVariables) + startInfo.Environment.Remove(variable); + } + + /// + /// Builds a copy of the current process environment without code-coverage profiler variables. + /// + /// A clean environment dictionary suitable for APIs that do not expose . + internal static Dictionary WithoutCodeCoverage() + { + var environment = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) + { + var key = (string)entry.Key; + if (CodeCoverageEnvironmentVariables.Contains(key)) + continue; + + environment[key] = entry.Value?.ToString(); + } + + return environment; + } + + /// + /// Builds a string-valued copy of the current process environment without code-coverage profiler variables. + /// + /// A clean environment dictionary suitable for terminal process helpers. + internal static Dictionary WithoutCodeCoverageStringValues() + { + var environment = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DictionaryEntry entry in Environment.GetEnvironmentVariables()) + { + var key = (string)entry.Key; + if (CodeCoverageEnvironmentVariables.Contains(key)) + continue; + + if (entry.Value is string value) + environment[key] = value; + } + + return environment; + } +} diff --git a/tests/Shared/TestSkip.cs b/tests/Shared/TestSkip.cs new file mode 100644 index 00000000..b20b690a --- /dev/null +++ b/tests/Shared/TestSkip.cs @@ -0,0 +1,30 @@ +/// +/// Provides MSTest skip helpers for runtime-dependent test assumptions. +/// MSTest represents these skips as inconclusive outcomes. +/// +internal static class TestSkip +{ + /// + /// Marks the test inconclusive when the condition is true. + /// Use this for optional runtime or platform capabilities that are absent. + /// + /// Whether the test should be skipped. + /// The skip reason. + public static void When(bool condition, string message) + { + if (condition) + Assert.Inconclusive(message); + } + + /// + /// Marks the test inconclusive unless the condition is true. + /// Use this for required runtime or platform capabilities that must be present. + /// + /// Whether the test can continue. + /// The skip reason. + public static void Unless(bool condition, string message) + { + if (!condition) + Assert.Inconclusive(message); + } +} diff --git a/tests/Shared/TestSocketIds.cs b/tests/Shared/TestSocketIds.cs new file mode 100644 index 00000000..6a0363e9 --- /dev/null +++ b/tests/Shared/TestSocketIds.cs @@ -0,0 +1,15 @@ +/// +/// Allocates unique pseudo-process identifiers for diagnostics socket tests. +/// The values avoid collisions between MethodLevel-parallel test cases. +/// +internal static class TestSocketIds +{ + private static int s_nextPid = 700_000; + + /// + /// Returns the next unique socket identifier for the current test process. + /// The value is process-local and intentionally does not need to match a real OS process id. + /// + /// A unique pseudo-process id. + public static int NextPid() => Interlocked.Increment(ref s_nextPid); +}