From 561b7386936d3ee18f6f0a1a467ce01997b15d6e Mon Sep 17 00:00:00 2001 From: Christoph Wille Date: Tue, 10 Nov 2026 08:10:37 +0100 Subject: [PATCH] Add unit tests for the weaver (ICSharpCode.ReferenceAssemblyAnnotator.Tests) xUnit v3 on Microsoft Testing Platform, net10.0. Covers the equivalence comparers and Program.Annotate/AnnotateType on in-memory Cecil modules, including the by-ref generic instance regression from #1. Runs in CI. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/build.yml | 8 +- CLAUDE.md | 10 +- .../AnnotateTests.cs | 101 ++++++++++++++++++ .../CecilBuilder.cs | 78 ++++++++++++++ .../EquivalenceComparersTests.cs | 99 +++++++++++++++++ ...de.ReferenceAssemblyAnnotator.Tests.csproj | 23 ++++ .../Program.cs | 4 +- .../Properties/AssemblyInfo.cs | 1 + ReferenceAssemblyAnnotator.slnx | 1 + global.json | 3 + 10 files changed, 323 insertions(+), 5 deletions(-) create mode 100644 ICSharpCode.ReferenceAssemblyAnnotator.Tests/AnnotateTests.cs create mode 100644 ICSharpCode.ReferenceAssemblyAnnotator.Tests/CecilBuilder.cs create mode 100644 ICSharpCode.ReferenceAssemblyAnnotator.Tests/EquivalenceComparersTests.cs create mode 100644 ICSharpCode.ReferenceAssemblyAnnotator.Tests/ICSharpCode.ReferenceAssemblyAnnotator.Tests.csproj diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c0fe376..69fa449 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,10 @@ jobs: - uses: actions/setup-dotnet@v5 with: - dotnet-version: '11.0.x' + # 10.0.x: runtime for the net10.0 unit test project. + dotnet-version: | + 10.0.x + 11.0.x dotnet-quality: 'preview' - name: Add msbuild to PATH @@ -36,6 +39,9 @@ jobs: - name: Build and pack run: dotnet build ReferenceAssemblyAnnotator.slnx -c $env:Configuration + - name: Unit tests + run: dotnet test --project ICSharpCode.ReferenceAssemblyAnnotator.Tests -c $env:Configuration --no-build + - name: Test (dotnet msbuild) run: ./build/test.ps1 -Configuration $env:Configuration -Engine dotnet -PackagesPath $env:TestPackages diff --git a/CLAUDE.md b/CLAUDE.md index c811463..0a3fa69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,13 @@ dotnet build ReferenceAssemblyAnnotator.slnx -c Release ## Tests -There is no unit test project. The tests are sample projects under `tests/` that must build cleanly with warnings as errors against the locally built package. Build the solution first, then: +`ICSharpCode.ReferenceAssemblyAnnotator.Tests` (xUnit v3 on Microsoft Testing Platform, net10.0) unit-tests the comparers and `Program.Annotate`/`AnnotateType` on in-memory Cecil modules built with `CecilBuilder`; it sees the weaver's internals via `InternalsVisibleTo`. `global.json` selects the MTP runner for `dotnet test`. After building the solution: + +``` +dotnet test --project ICSharpCode.ReferenceAssemblyAnnotator.Tests -c Release --no-build +``` + +The integration tests are sample projects under `tests/` that must build cleanly with warnings as errors against the locally built package. Build the solution first, then: ``` ./build/test.ps1 -Configuration Release -Engine dotnet # dotnet msbuild @@ -39,7 +45,7 @@ dotnet msbuild -restore tests/SingleTFM -p:TestFramework=netstandard2.0 -warnase - The test projects leave out the `Microsoft.NETCore.App.Ref` `PackageDownload` for .NET Core 3.0+ (netcoreapp3.x, net10.0). Those aren't annotated, and the SDK adds its own download whenever the targeting pack isn't installed, so a second one fails with NU1505. The `GetHashCode` line in the test sources is `#if !NETCOREAPP3_0_OR_GREATER` because only annotated TFMs get `[DisallowNull]` removed (`Program.IsExcludedAnnotation`). - Keep the local source path in `tests/NuGet.Config` with forward slashes: with backslashes, source mapping doesn't match it on Linux/macOS (NU1100). -CI: `.github/workflows/build.yml` runs the same build and test script with both engines (Release only) and uploads the nupkg. On the runner, paths in the isolated cache exceed MAX_PATH under `msbuild.exe`. So the workflow passes `-PackagesPath` with a short path outside the checkout; that cache isn't wiped between cases, which is safe because a run only ever contains one annotator version. `test.ps1` also passes `core.longpaths=true` to `git clean`. +CI: `.github/workflows/build.yml` runs the same build, the unit tests and the test script with both engines (Release only) and uploads the nupkg. On the runner, paths in the isolated cache exceed MAX_PATH under `msbuild.exe`. So the workflow passes `-PackagesPath` with a short path outside the checkout; that cache isn't wiped between cases, which is safe because a run only ever contains one annotator version. `test.ps1` also passes `core.longpaths=true` to `git clean`. ## Architecture diff --git a/ICSharpCode.ReferenceAssemblyAnnotator.Tests/AnnotateTests.cs b/ICSharpCode.ReferenceAssemblyAnnotator.Tests/AnnotateTests.cs new file mode 100644 index 0000000..4c74e10 --- /dev/null +++ b/ICSharpCode.ReferenceAssemblyAnnotator.Tests/AnnotateTests.cs @@ -0,0 +1,101 @@ +// Copyright (c) Tunnel Vision Laboratories, LLC. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace ICSharpCode.ReferenceAssemblyAnnotator.Tests +{ + using System.Collections.Generic; + using System.Linq; + using Mono.Cecil; + using Mono.Cecil.Rocks; + using Xunit; + using static CecilBuilder; + + public class AnnotateTests + { + private readonly ModuleDefinition _module = CreateModule("Unannotated"); + private readonly ModuleDefinition _annotatedModule = CreateModule("Annotated"); + private readonly TypeDefinition _nullable; + private readonly TypeDefinition _annotatedNullable; + private readonly TypeDefinition _disallowNull; + private readonly TypeDefinition _annotatedDisallowNull; + private readonly Dictionary _attributesOfInterest; + + public AnnotateTests() + { + // As in Program.Main, the attributes of interest are types the unannotated module can reference. + _nullable = AddAttributeType(_module, NullableAttribute, _module.TypeSystem.Byte); + _annotatedNullable = AddAttributeType(_annotatedModule, NullableAttribute, _annotatedModule.TypeSystem.Byte); + _disallowNull = AddAttributeType(_module, DisallowNullAttribute); + _annotatedDisallowNull = AddAttributeType(_annotatedModule, DisallowNullAttribute); + _attributesOfInterest = new Dictionary + { + [NullableAttribute] = _nullable, + [DisallowNullAttribute] = _disallowNull, + }; + } + + [Fact] + public void AnnotateType_MatchesByRefGenericOverloads() + { + // Regression: these overloads used to compare equal, so FindMatchingMethod threw. + var type = AddType(_module, "N", "C"); + var boolOverload = AddMethod(type, "M", Nullable(_module, _module.TypeSystem.Boolean).MakeByReferenceType()); + var byteOverload = AddMethod(type, "M", Nullable(_module, _module.TypeSystem.Byte).MakeByReferenceType()); + + var annotatedType = AddType(_annotatedModule, "N", "C"); + var annotatedBool = AddMethod(annotatedType, "M", Nullable(_annotatedModule, _annotatedModule.TypeSystem.Boolean).MakeByReferenceType()); + var annotatedByte = AddMethod(annotatedType, "M", Nullable(_annotatedModule, _annotatedModule.TypeSystem.Byte).MakeByReferenceType()); + AddAttribute(annotatedBool.Parameters[0], _annotatedNullable, NullableFlag(1)); + AddAttribute(annotatedByte.Parameters[0], _annotatedNullable, NullableFlag(2)); + + Program.AnnotateType(null, _module, type, _annotatedModule, _attributesOfInterest); + + Assert.Equal((byte)1, SingleNullableFlag(boolOverload.Parameters[0])); + Assert.Equal((byte)2, SingleNullableFlag(byteOverload.Parameters[0])); + } + + [Fact] + public void Annotate_ReplacesAttributesOfInterestAndKeepsOthers() + { + var keep = AddAttributeType(_module, "N.KeepAttribute"); + var parameter = AddMethod(AddType(_module, "N", "C"), "M", _module.TypeSystem.String).Parameters[0]; + AddAttribute(parameter, _nullable, NullableFlag(1)); + AddAttribute(parameter, keep); + + var annotatedParameter = AddMethod(AddType(_annotatedModule, "N", "C"), "M", _annotatedModule.TypeSystem.String).Parameters[0]; + AddAttribute(annotatedParameter, _annotatedNullable, NullableFlag(2)); + + Program.Annotate(_module, parameter, annotatedParameter, _attributesOfInterest); + + Assert.Equal((byte)2, SingleNullableFlag(parameter)); + Assert.Contains(parameter.CustomAttributes, attribute => attribute.AttributeType == keep); + Assert.Equal(2, parameter.CustomAttributes.Count); + } + + [Theory] + [InlineData("EqualityComparer`1")] + [InlineData("IEqualityComparer`1")] + public void AnnotateType_DropsDisallowNullOnGetHashCode(string typeName) + { + var type = AddType(_module, "System.Collections.Generic", typeName, genericArity: 1); + var getHashCode = AddMethod(type, "GetHashCode", type.GenericParameters[0]); + AddAttribute(getHashCode.Parameters[0], _disallowNull); + + var annotatedType = AddType(_annotatedModule, "System.Collections.Generic", typeName, genericArity: 1); + var annotatedParameter = AddMethod(annotatedType, "GetHashCode", annotatedType.GenericParameters[0]).Parameters[0]; + AddAttribute(annotatedParameter, _annotatedDisallowNull); + AddAttribute(annotatedParameter, _annotatedNullable, NullableFlag(1)); + + Program.AnnotateType(null, _module, type, _annotatedModule, _attributesOfInterest); + + Assert.DoesNotContain(getHashCode.Parameters[0].CustomAttributes, attribute => attribute.AttributeType.FullName == DisallowNullAttribute); + Assert.Equal((byte)1, SingleNullableFlag(getHashCode.Parameters[0])); + } + + private static byte SingleNullableFlag(ICustomAttributeProvider provider) + => (byte)provider.CustomAttributes.Single(attribute => attribute.AttributeType.FullName == NullableAttribute).ConstructorArguments[0].Value; + + private CustomAttributeArgument NullableFlag(byte value) + => new CustomAttributeArgument(_annotatedModule.TypeSystem.Byte, value); + } +} diff --git a/ICSharpCode.ReferenceAssemblyAnnotator.Tests/CecilBuilder.cs b/ICSharpCode.ReferenceAssemblyAnnotator.Tests/CecilBuilder.cs new file mode 100644 index 0000000..9bf4cfe --- /dev/null +++ b/ICSharpCode.ReferenceAssemblyAnnotator.Tests/CecilBuilder.cs @@ -0,0 +1,78 @@ +// Copyright (c) Tunnel Vision Laboratories, LLC. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace ICSharpCode.ReferenceAssemblyAnnotator.Tests +{ + using System.Linq; + using Mono.Cecil; + using Mono.Cecil.Rocks; + + /// + /// Builds small in-memory Cecil modules for the tests. + /// + internal static class CecilBuilder + { + public const string NullableAttribute = "System.Runtime.CompilerServices.NullableAttribute"; + public const string DisallowNullAttribute = "System.Diagnostics.CodeAnalysis.DisallowNullAttribute"; + + public static ModuleDefinition CreateModule(string name) + => ModuleDefinition.CreateModule(name, ModuleKind.Dll); + + public static TypeDefinition AddType(ModuleDefinition module, string @namespace, string name, int genericArity = 0) + { + var type = new TypeDefinition(@namespace, name, TypeAttributes.Public | TypeAttributes.Class, module.TypeSystem.Object); + for (int i = 0; i < genericArity; i++) + type.GenericParameters.Add(new GenericParameter("T" + i, type)); + + module.Types.Add(type); + return type; + } + + public static MethodDefinition AddMethod(TypeDefinition type, string name, params TypeReference[] parameterTypes) + { + var method = new MethodDefinition(name, MethodAttributes.Public, type.Module.TypeSystem.Void); + foreach (var parameterType in parameterTypes) + method.Parameters.Add(new ParameterDefinition(parameterType)); + + type.Methods.Add(method); + return method; + } + + // Adds an attribute type with a constructor taking the given parameter types. + public static TypeDefinition AddAttributeType(ModuleDefinition module, string fullName, params TypeReference[] constructorParameterTypes) + { + int dot = fullName.LastIndexOf('.'); + var type = AddType(module, fullName.Substring(0, dot), fullName.Substring(dot + 1)); + var constructor = new MethodDefinition( + ".ctor", + MethodAttributes.Public | MethodAttributes.HideBySig | MethodAttributes.SpecialName | MethodAttributes.RTSpecialName, + module.TypeSystem.Void); + foreach (var parameterType in constructorParameterTypes) + constructor.Parameters.Add(new ParameterDefinition(parameterType)); + + type.Methods.Add(constructor); + return type; + } + + public static void AddAttribute(ICustomAttributeProvider provider, TypeDefinition attributeType, params CustomAttributeArgument[] arguments) + { + var attribute = new CustomAttribute(attributeType.Methods.Single(method => method.IsConstructor)); + foreach (var argument in arguments) + attribute.ConstructorArguments.Add(argument); + + provider.CustomAttributes.Add(attribute); + } + + public static TypeReference Nullable(ModuleDefinition module, TypeReference typeArgument) + => Generic(module, "System", "Nullable`1", isValueType: true, typeArgument); + + public static TypeReference Generic(ModuleDefinition module, string @namespace, string name, bool isValueType, params TypeReference[] typeArguments) + { + var open = new TypeReference(@namespace, name, module, module.TypeSystem.CoreLibrary, isValueType); + for (int i = 0; i < typeArguments.Length; i++) + open.GenericParameters.Add(new GenericParameter(open)); + + return open.MakeGenericInstanceType(typeArguments); + } + } +} diff --git a/ICSharpCode.ReferenceAssemblyAnnotator.Tests/EquivalenceComparersTests.cs b/ICSharpCode.ReferenceAssemblyAnnotator.Tests/EquivalenceComparersTests.cs new file mode 100644 index 0000000..b9b3ddb --- /dev/null +++ b/ICSharpCode.ReferenceAssemblyAnnotator.Tests/EquivalenceComparersTests.cs @@ -0,0 +1,99 @@ +// Copyright (c) Tunnel Vision Laboratories, LLC. All Rights Reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +namespace ICSharpCode.ReferenceAssemblyAnnotator.Tests +{ + using Mono.Cecil; + using Mono.Cecil.Rocks; + using Xunit; + using static CecilBuilder; + + public class EquivalenceComparersTests + { + private readonly ModuleDefinition _module = CreateModule("A"); + private readonly ModuleDefinition _otherModule = CreateModule("B"); + + [Fact] + public void ByReference_DistinguishesGenericArguments() + { + // Regression: GetElementType() on Nullable& returns the open Nullable`1. + var x = Nullable(_module, _module.TypeSystem.Boolean).MakeByReferenceType(); + var y = Nullable(_module, _module.TypeSystem.Byte).MakeByReferenceType(); + + Assert.False(EquivalenceComparers.TypeReference.Equals(x, y)); + } + + [Fact] + public void Pointer_DistinguishesGenericArguments() + { + var x = Nullable(_module, _module.TypeSystem.Boolean).MakePointerType(); + var y = Nullable(_module, _module.TypeSystem.Byte).MakePointerType(); + + Assert.False(EquivalenceComparers.TypeReference.Equals(x, y)); + } + + [Fact] + public void Array_DistinguishesGenericArguments() + { + var x = Nullable(_module, _module.TypeSystem.Boolean).MakeArrayType(); + var y = Nullable(_module, _module.TypeSystem.Byte).MakeArrayType(); + + Assert.False(EquivalenceComparers.TypeReference.Equals(x, y)); + } + + [Fact] + public void Array_DistinguishesRank() + { + var x = _module.TypeSystem.Int32.MakeArrayType(1); + var y = _module.TypeSystem.Int32.MakeArrayType(2); + + Assert.False(EquivalenceComparers.TypeReference.Equals(x, y)); + } + + [Fact] + public void SameShapeFromDifferentModules_AreEqual() + { + var x = Nullable(_module, _module.TypeSystem.Boolean).MakeByReferenceType().MakeArrayType(); + var y = Nullable(_otherModule, _otherModule.TypeSystem.Boolean).MakeByReferenceType().MakeArrayType(); + + Assert.True(EquivalenceComparers.TypeReference.Equals(x, y)); + Assert.Equal(EquivalenceComparers.TypeReference.GetHashCode(x), EquivalenceComparers.TypeReference.GetHashCode(y)); + } + + [Fact] + public void Nested_DistinguishedFromTopLevel() + { + var outer = AddType(_module, "N", "Outer"); + var nested = new TypeDefinition(string.Empty, "Inner", TypeAttributes.NestedPublic, _module.TypeSystem.Object); + outer.NestedTypes.Add(nested); + var topLevel = AddType(_otherModule, string.Empty, "Inner"); + + Assert.False(EquivalenceComparers.TypeReference.Equals(nested, topLevel)); + Assert.False(EquivalenceComparers.TypeReference.Equals(topLevel, nested)); + } + + [Fact] + public void GenericArity_Matters() + { + var x = AddType(_module, "N", "C"); + var y = AddType(_otherModule, "N", "C", genericArity: 1); + + Assert.False(EquivalenceComparers.TypeReference.Equals(x, y)); + Assert.False(EquivalenceComparers.TypeDefinition.Equals(x, y)); + } + + [Fact] + public void MethodDefinition_DistinguishesByRefGenericOverloads() + { + var type = AddType(_module, "N", "C"); + var boolOverload = AddMethod(type, "M", Nullable(_module, _module.TypeSystem.Boolean).MakeByReferenceType()); + var byteOverload = AddMethod(type, "M", Nullable(_module, _module.TypeSystem.Byte).MakeByReferenceType()); + + var otherType = AddType(_otherModule, "N", "C"); + var otherBoolOverload = AddMethod(otherType, "M", Nullable(_otherModule, _otherModule.TypeSystem.Boolean).MakeByReferenceType()); + + Assert.True(EquivalenceComparers.MethodDefinition.Equals(boolOverload, otherBoolOverload)); + Assert.False(EquivalenceComparers.MethodDefinition.Equals(byteOverload, otherBoolOverload)); + } + } +} diff --git a/ICSharpCode.ReferenceAssemblyAnnotator.Tests/ICSharpCode.ReferenceAssemblyAnnotator.Tests.csproj b/ICSharpCode.ReferenceAssemblyAnnotator.Tests/ICSharpCode.ReferenceAssemblyAnnotator.Tests.csproj new file mode 100644 index 0000000..1cff9b2 --- /dev/null +++ b/ICSharpCode.ReferenceAssemblyAnnotator.Tests/ICSharpCode.ReferenceAssemblyAnnotator.Tests.csproj @@ -0,0 +1,23 @@ + + + + net10.0 + Exe + false + + + + ..\ICSharpCode.ReferenceAssemblyAnnotator.ruleset + + + + + + + + + + + + + diff --git a/ICSharpCode.ReferenceAssemblyAnnotator/Program.cs b/ICSharpCode.ReferenceAssemblyAnnotator/Program.cs index d44503a..131dde8 100644 --- a/ICSharpCode.ReferenceAssemblyAnnotator/Program.cs +++ b/ICSharpCode.ReferenceAssemblyAnnotator/Program.cs @@ -89,7 +89,7 @@ private static void AnnotateModule(SuppressibleLoggingHelper? log, ModuleDefinit } } - private static void AnnotateType(SuppressibleLoggingHelper? log, ModuleDefinition module, TypeDefinition typeDefinition, ModuleDefinition annotatedModuleDefinition, Dictionary attributesOfInterest) + internal static void AnnotateType(SuppressibleLoggingHelper? log, ModuleDefinition module, TypeDefinition typeDefinition, ModuleDefinition annotatedModuleDefinition, Dictionary attributesOfInterest) { if (attributesOfInterest.ContainsKey(typeDefinition.FullName)) return; @@ -174,7 +174,7 @@ private static void AnnotateField(ModuleDefinition module, FieldDefinition field Annotate(module, fieldDefinition, annotatedFieldDefinition, attributesOfInterest); } - private static void Annotate(ModuleDefinition module, ICustomAttributeProvider provider, ICustomAttributeProvider annotatedProvider, Dictionary attributesOfInterest) + internal static void Annotate(ModuleDefinition module, ICustomAttributeProvider provider, ICustomAttributeProvider annotatedProvider, Dictionary attributesOfInterest) { // Start by removing excluded attributes as well as attributes of interest to allow, say, rewriting // netcoreapp3.1 to use .NET 5 reference annotations. diff --git a/ICSharpCode.ReferenceAssemblyAnnotator/Properties/AssemblyInfo.cs b/ICSharpCode.ReferenceAssemblyAnnotator/Properties/AssemblyInfo.cs index d72ff73..8a593a6 100644 --- a/ICSharpCode.ReferenceAssemblyAnnotator/Properties/AssemblyInfo.cs +++ b/ICSharpCode.ReferenceAssemblyAnnotator/Properties/AssemblyInfo.cs @@ -4,3 +4,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("CecilBasedAnnotator, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f9a0f527c8e6c74b50a18e7460592e6292488891676d935bf828712a126857ef2e8882e4ff7f9576b9f3b51d5f248b0fedda28f244f92f795beeee9ef04382c0b305da94ab04ed32831815ea27dc475bf4427f059f7d10cc4a025d0f30f5e2a07eadf1e205a1d92788c2e7dee5bc19dba7aa1b9863205d1784e164d346ea89be")] +[assembly: InternalsVisibleTo("ICSharpCode.ReferenceAssemblyAnnotator.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f9a0f527c8e6c74b50a18e7460592e6292488891676d935bf828712a126857ef2e8882e4ff7f9576b9f3b51d5f248b0fedda28f244f92f795beeee9ef04382c0b305da94ab04ed32831815ea27dc475bf4427f059f7d10cc4a025d0f30f5e2a07eadf1e205a1d92788c2e7dee5bc19dba7aa1b9863205d1784e164d346ea89be")] diff --git a/ReferenceAssemblyAnnotator.slnx b/ReferenceAssemblyAnnotator.slnx index 9e287de..61bc9c1 100644 --- a/ReferenceAssemblyAnnotator.slnx +++ b/ReferenceAssemblyAnnotator.slnx @@ -7,5 +7,6 @@ + diff --git a/global.json b/global.json index e9c718d..115ca73 100644 --- a/global.json +++ b/global.json @@ -3,5 +3,8 @@ "version": "11.0.0", "rollForward": "major", "allowPrerelease": true + }, + "test": { + "runner": "Microsoft.Testing.Platform" } }