From 82de686cb430be7ae89c12e11b77125ae5e6c51d Mon Sep 17 00:00:00 2001 From: James A Sutherland Date: Tue, 13 Oct 2026 19:53:56 -0500 Subject: [PATCH 1/3] Replace Verify with in-house generator snapshot assertions Verify 33.x adds a SponsorCheck build gate that fails the build unless a sponsorship/licence MSBuild property is declared. Verify was only used for 14 source generator snapshot tests, so replace it with a small helper. - Add GeneratorSnapshot, which compares each generated source against the existing {Class}.{Test}#{HintName}.verified.cs files, writes .received.cs on mismatch, flags stale snapshots, and fails on generator diagnostics or exceptions - Exclude *.verified.cs / *.received.cs from compilation (previously done by Verify's MSBuild targets) - Remove Verify.NUnit and Verify.SourceGenerators packages and the dependabot group pattern --- .github/dependabot.yml | 1 - Directory.Packages.props | 3 - .../SharpDicom.Tests.Polyfills.csproj | 6 +- .../DicomDictionaryGeneratorTests.cs | 40 +++++---- .../Generators/GeneratorSnapshot.cs | 85 +++++++++++++++++++ .../Generators/Part15ParserTests.cs | 30 +++---- tests/SharpDicom.Tests/ModuleInitializer.cs | 19 ----- .../SharpDicom.Tests/SharpDicom.Tests.csproj | 7 +- 8 files changed, 125 insertions(+), 66 deletions(-) create mode 100644 tests/SharpDicom.Tests/Generators/GeneratorSnapshot.cs delete mode 100644 tests/SharpDicom.Tests/ModuleInitializer.cs diff --git a/.github/dependabot.yml b/.github/dependabot.yml index a87cf069..76c75882 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -15,7 +15,6 @@ updates: patterns: - "NUnit*" - "coverlet*" - - "Verify*" - package-ecosystem: github-actions directory: / diff --git a/Directory.Packages.props b/Directory.Packages.props index ef3978fa..1fb2c7ad 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -33,9 +33,6 @@ - - - diff --git a/tests/SharpDicom.Tests.Polyfills/SharpDicom.Tests.Polyfills.csproj b/tests/SharpDicom.Tests.Polyfills/SharpDicom.Tests.Polyfills.csproj index d2255072..afa989c4 100644 --- a/tests/SharpDicom.Tests.Polyfills/SharpDicom.Tests.Polyfills.csproj +++ b/tests/SharpDicom.Tests.Polyfills/SharpDicom.Tests.Polyfills.csproj @@ -18,8 +18,6 @@ - - @@ -30,8 +28,8 @@ - + - + diff --git a/tests/SharpDicom.Tests/Generators/DicomDictionaryGeneratorTests.cs b/tests/SharpDicom.Tests/Generators/DicomDictionaryGeneratorTests.cs index 9a6dbe2b..284ae4e7 100644 --- a/tests/SharpDicom.Tests/Generators/DicomDictionaryGeneratorTests.cs +++ b/tests/SharpDicom.Tests/Generators/DicomDictionaryGeneratorTests.cs @@ -1,21 +1,19 @@ using System.Collections.Immutable; -using System.Threading.Tasks; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using NUnit.Framework; using SharpDicom.Generators; -using VerifyNUnit; namespace SharpDicom.Tests.Generators; /// -/// Snapshot tests for the DicomDictionaryGenerator using Verify.SourceGenerators. +/// Snapshot tests for the DicomDictionaryGenerator. /// [TestFixture] public class DicomDictionaryGeneratorTests { [Test] - public Task GeneratesTagsFromMinimalXml() + public void GeneratesTagsFromMinimalXml() { // Minimal Part 6-like XML with one tag var xml = """ @@ -39,11 +37,11 @@ public Task GeneratesTagsFromMinimalXml() """; - return VerifyGenerator(xml, "part06.xml"); + VerifyGenerator(xml, "part06.xml"); } [Test] - public Task GeneratesMultiVRTag() + public void GeneratesMultiVRTag() { // Tag with multiple VRs (US or SS) var xml = """ @@ -67,11 +65,11 @@ public Task GeneratesMultiVRTag() """; - return VerifyGenerator(xml, "part06.xml"); + VerifyGenerator(xml, "part06.xml"); } [Test] - public Task GeneratesRetiredTag() + public void GeneratesRetiredTag() { // Retired tag (should be marked with IsRetired=true) var xml = """ @@ -95,11 +93,11 @@ public Task GeneratesRetiredTag() """; - return VerifyGenerator(xml, "part06.xml"); + VerifyGenerator(xml, "part06.xml"); } [Test] - public Task GeneratesUIDs() + public void GeneratesUIDs() { // UID registry table var xml = """ @@ -129,11 +127,11 @@ public Task GeneratesUIDs() """; - return VerifyGenerator(xml, "part06.xml"); + VerifyGenerator(xml, "part06.xml"); } [Test] - public Task HandlesMaskedTag() + public void HandlesMaskedTag() { // Masked tag (50xx,0010) var xml = """ @@ -157,11 +155,11 @@ public Task HandlesMaskedTag() """; - return VerifyGenerator(xml, "part06.xml"); + VerifyGenerator(xml, "part06.xml"); } [Test] - public Task HandlesEmptyXml() + public void HandlesEmptyXml() { var xml = """ @@ -169,19 +167,19 @@ public Task HandlesEmptyXml() """; - return VerifyGenerator(xml, "part06.xml"); + VerifyGenerator(xml, "part06.xml"); } [Test] - public Task ReportsDiagnosticForInvalidXml() + public void ReportsDiagnosticForInvalidXml() { var xml = "not valid xml"; - return VerifyGenerator(xml, "part06.xml"); + VerifyGenerator(xml, "part06.xml"); } [Test] - public Task GeneratesTagsAndUIDsCombined() + public void GeneratesTagsAndUIDsCombined() { // Combined tags and UIDs in same XML var xml = """ @@ -217,10 +215,10 @@ public Task GeneratesTagsAndUIDsCombined() """; - return VerifyGenerator(xml, "part06.xml"); + VerifyGenerator(xml, "part06.xml"); } - private static Task VerifyGenerator(string xmlContent, string fileName) + private static void VerifyGenerator(string xmlContent, string fileName) { // Create compilation // IL3000: Assembly.Location is required for source generator tests to get metadata references @@ -246,6 +244,6 @@ private static Task VerifyGenerator(string xmlContent, string fileName) driver = driver.RunGenerators(compilation); // Verify snapshot - return Verifier.Verify(driver); + GeneratorSnapshot.Verify(driver); } } diff --git a/tests/SharpDicom.Tests/Generators/GeneratorSnapshot.cs b/tests/SharpDicom.Tests/Generators/GeneratorSnapshot.cs new file mode 100644 index 00000000..fe0fba8e --- /dev/null +++ b/tests/SharpDicom.Tests/Generators/GeneratorSnapshot.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using Microsoft.CodeAnalysis; +using NUnit.Framework; + +namespace SharpDicom.Tests.Generators; + +/// +/// Snapshot assertions for source generator output. +/// +/// +/// Each generated source is compared against +/// {TestClass}.{TestMethod}#{HintName}.verified.cs next to the calling test file. +/// On mismatch a .received.cs file is written alongside for review; accept a change +/// by renaming it to .verified.cs. +/// +internal static class GeneratorSnapshot +{ + private static readonly UTF8Encoding Utf8WithBom = new(encoderShouldEmitUTF8Identifier: true); + + /// + /// Asserts that the generator run produced no diagnostics or exceptions and that + /// its output matches the verified snapshots for the current test. + /// + /// The generator driver after RunGenerators. + /// Path of the calling test file (supplied by the compiler). + public static void Verify(GeneratorDriver driver, [CallerFilePath] string callerFilePath = "") + { + var runResult = driver.GetRunResult(); + var exceptions = runResult.Results.Where(r => r.Exception is not null).Select(r => r.Exception!.ToString()); + Assert.That(exceptions, Is.Empty, "Generator threw an exception"); + Assert.That(runResult.Diagnostics.Select(d => d.ToString()), Is.Empty, "Generator reported diagnostics"); + + var directory = Path.GetDirectoryName(callerFilePath)!; + var test = TestContext.CurrentContext.Test; + var className = test.ClassName!.Substring(test.ClassName.LastIndexOf('.') + 1); + var prefix = $"{className}.{test.MethodName}#"; + + var failures = new List(); + var expectedFiles = new HashSet(StringComparer.Ordinal); + + foreach (var generated in runResult.Results.SelectMany(r => r.GeneratedSources)) + { + var baseName = prefix + Path.GetFileNameWithoutExtension(generated.HintName); + var verifiedPath = Path.Combine(directory, baseName + ".verified.cs"); + var receivedPath = Path.Combine(directory, baseName + ".received.cs"); + expectedFiles.Add(Path.GetFileName(verifiedPath)); + + var actual = Normalize($"//HintName: {generated.HintName}\n{generated.SourceText}"); + var expected = File.Exists(verifiedPath) ? Normalize(File.ReadAllText(verifiedPath)) : null; + + if (actual == expected) + { + if (File.Exists(receivedPath)) + { + File.Delete(receivedPath); + } + + continue; + } + + File.WriteAllText(receivedPath, actual + "\n", Utf8WithBom); + failures.Add(expected is null + ? $"Missing snapshot {Path.GetFileName(verifiedPath)}; wrote {Path.GetFileName(receivedPath)}" + : $"Snapshot mismatch for {Path.GetFileName(verifiedPath)}; wrote {Path.GetFileName(receivedPath)}"); + } + + foreach (var stale in Directory.GetFiles(directory, prefix + "*.verified.cs")) + { + if (!expectedFiles.Contains(Path.GetFileName(stale))) + { + failures.Add($"Snapshot {Path.GetFileName(stale)} has no corresponding generated source"); + } + } + + Assert.That(failures, Is.Empty, "Generator output does not match snapshots"); + } + + private static string Normalize(string text) => + text.TrimStart('').Replace("\r\n", "\n").TrimEnd(); +} diff --git a/tests/SharpDicom.Tests/Generators/Part15ParserTests.cs b/tests/SharpDicom.Tests/Generators/Part15ParserTests.cs index b6766bae..9c0e47ad 100644 --- a/tests/SharpDicom.Tests/Generators/Part15ParserTests.cs +++ b/tests/SharpDicom.Tests/Generators/Part15ParserTests.cs @@ -1,10 +1,8 @@ using System.Collections.Immutable; -using System.Threading.Tasks; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using NUnit.Framework; using SharpDicom.Generators; -using VerifyNUnit; namespace SharpDicom.Tests.Generators; @@ -15,7 +13,7 @@ namespace SharpDicom.Tests.Generators; public class Part15ParserTests { [Test] - public Task ParsesBasicProfileActions() + public void ParsesBasicProfileActions() { // Table E.1-1 structure with common attributes var xml = """ @@ -67,11 +65,11 @@ public Task ParsesBasicProfileActions() """; - return VerifyGenerator(xml); + VerifyGenerator(xml); } [Test] - public Task ParsesPatientName() + public void ParsesPatientName() { // Patient Name with Z action var xml = """ @@ -104,11 +102,11 @@ public Task ParsesPatientName() """; - return VerifyGenerator(xml); + VerifyGenerator(xml); } [Test] - public Task ParsesSOPInstanceUID() + public void ParsesSOPInstanceUID() { // SOP Instance UID with U action and RetainUIDs option var xml = """ @@ -141,11 +139,11 @@ public Task ParsesSOPInstanceUID() """; - return VerifyGenerator(xml); + VerifyGenerator(xml); } [Test] - public Task ParsesRetiredAttribute() + public void ParsesRetiredAttribute() { // Retired attribute (Acquisition Comments) var xml = """ @@ -178,11 +176,11 @@ public Task ParsesRetiredAttribute() """; - return VerifyGenerator(xml); + VerifyGenerator(xml); } [Test] - public Task ParsesCompoundActions() + public void ParsesCompoundActions() { // Compound action X/Z and X/Z/D var xml = """ @@ -232,11 +230,11 @@ public Task ParsesCompoundActions() """; - return VerifyGenerator(xml); + VerifyGenerator(xml); } [Test] - public Task ParsesAllProfileOptions() + public void ParsesAllProfileOptions() { // Institution Name with multiple profile options var xml = """ @@ -269,10 +267,10 @@ public Task ParsesAllProfileOptions() """; - return VerifyGenerator(xml); + VerifyGenerator(xml); } - private static Task VerifyGenerator(string xmlContent) + private static void VerifyGenerator(string xmlContent) { // Create compilation #pragma warning disable IL3000 // Required for source generator tests @@ -297,6 +295,6 @@ private static Task VerifyGenerator(string xmlContent) driver = driver.RunGenerators(compilation); // Verify snapshot - return Verifier.Verify(driver); + GeneratorSnapshot.Verify(driver); } } diff --git a/tests/SharpDicom.Tests/ModuleInitializer.cs b/tests/SharpDicom.Tests/ModuleInitializer.cs deleted file mode 100644 index e0404289..00000000 --- a/tests/SharpDicom.Tests/ModuleInitializer.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Runtime.CompilerServices; -using VerifyTests; - -namespace SharpDicom.Tests; - -/// -/// Module initializer for Verify.SourceGenerators setup. -/// -public static class ModuleInitializer -{ - /// - /// Initializes Verify.SourceGenerators settings. - /// - [ModuleInitializer] - public static void Initialize() - { - VerifySourceGenerators.Initialize(); - } -} diff --git a/tests/SharpDicom.Tests/SharpDicom.Tests.csproj b/tests/SharpDicom.Tests/SharpDicom.Tests.csproj index 877f654d..4bcd21a1 100644 --- a/tests/SharpDicom.Tests/SharpDicom.Tests.csproj +++ b/tests/SharpDicom.Tests/SharpDicom.Tests.csproj @@ -17,8 +17,6 @@ - - @@ -28,4 +26,9 @@ + + + + + From 62841c027e02aa8fbf020df18429065a62ee3058 Mon Sep 17 00:00:00 2001 From: James A Sutherland Date: Tue, 13 Oct 2026 19:53:56 -0500 Subject: [PATCH 2/3] Fix stale NativeCodecs enable-options test Enable options are applied before the native library is probed, so they take effect even when the library is unavailable. The test asserted the opposite in its library-unavailable branch, which CI never exercised. --- .../NativeCodecsTests.cs | 25 +++++-------------- 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/tests/SharpDicom.Codecs.Tests/NativeCodecsTests.cs b/tests/SharpDicom.Codecs.Tests/NativeCodecsTests.cs index e93b447b..5f89dec5 100644 --- a/tests/SharpDicom.Codecs.Tests/NativeCodecsTests.cs +++ b/tests/SharpDicom.Codecs.Tests/NativeCodecsTests.cs @@ -428,8 +428,8 @@ public void NativeCodecOptions_CanSetAllProperties() [Test] public void Initialize_WithEnableOptions_SetsProperties() { - // Note: Enable options are only applied during successful initialization - // When native library is unavailable, options aren't applied after DllNotFoundException + // Enable options are applied before the native library is probed, + // so they take effect whether or not the library loads NativeCodecs.Initialize(new NativeCodecOptions { SuppressInitializationErrors = true, @@ -439,23 +439,10 @@ public void Initialize_WithEnableOptions_SetsProperties() EnableVideo = false }); - // If native library was loaded successfully, enable options should be set - if (NativeCodecs.IsAvailable) - { - Assert.That(NativeCodecs.EnableJpeg, Is.False); - Assert.That(NativeCodecs.EnableJpeg2000, Is.False); - Assert.That(NativeCodecs.EnableJpegLs, Is.False); - Assert.That(NativeCodecs.EnableVideo, Is.False); - } - else - { - // When library unavailable, options weren't applied - properties remain default - // This is a design limitation when native libraries are missing - Assert.That(NativeCodecs.EnableJpeg, Is.True, "Default value when library unavailable"); - Assert.That(NativeCodecs.EnableJpeg2000, Is.True, "Default value when library unavailable"); - Assert.That(NativeCodecs.EnableJpegLs, Is.True, "Default value when library unavailable"); - Assert.That(NativeCodecs.EnableVideo, Is.True, "Default value when library unavailable"); - } + Assert.That(NativeCodecs.EnableJpeg, Is.False); + Assert.That(NativeCodecs.EnableJpeg2000, Is.False); + Assert.That(NativeCodecs.EnableJpegLs, Is.False); + Assert.That(NativeCodecs.EnableVideo, Is.False); } #endregion From fac71e3159ce44ee170d35caf77da97b2ee5bfa4 Mon Sep 17 00:00:00 2001 From: James A Sutherland Date: Tue, 13 Oct 2026 20:06:50 -0500 Subject: [PATCH 3/3] Resolve generator snapshot paths under CI path mapping Deterministic CI builds record CallerFilePath as /_/..., so snapshot files could not be found. Embed the repository root as assembly metadata and map /_/ paths back onto it. --- .../SharpDicom.Tests.Polyfills.csproj | 5 ++++ .../Generators/GeneratorSnapshot.cs | 24 ++++++++++++++++++- .../SharpDicom.Tests/SharpDicom.Tests.csproj | 5 ++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/tests/SharpDicom.Tests.Polyfills/SharpDicom.Tests.Polyfills.csproj b/tests/SharpDicom.Tests.Polyfills/SharpDicom.Tests.Polyfills.csproj index afa989c4..eb595df0 100644 --- a/tests/SharpDicom.Tests.Polyfills/SharpDicom.Tests.Polyfills.csproj +++ b/tests/SharpDicom.Tests.Polyfills/SharpDicom.Tests.Polyfills.csproj @@ -32,4 +32,9 @@ + + + + + diff --git a/tests/SharpDicom.Tests/Generators/GeneratorSnapshot.cs b/tests/SharpDicom.Tests/Generators/GeneratorSnapshot.cs index fe0fba8e..3fe2c461 100644 --- a/tests/SharpDicom.Tests/Generators/GeneratorSnapshot.cs +++ b/tests/SharpDicom.Tests/Generators/GeneratorSnapshot.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection; using System.Runtime.CompilerServices; using System.Text; using Microsoft.CodeAnalysis; @@ -35,7 +36,7 @@ public static void Verify(GeneratorDriver driver, [CallerFilePath] string caller Assert.That(exceptions, Is.Empty, "Generator threw an exception"); Assert.That(runResult.Diagnostics.Select(d => d.ToString()), Is.Empty, "Generator reported diagnostics"); - var directory = Path.GetDirectoryName(callerFilePath)!; + var directory = Path.GetDirectoryName(ResolveSourcePath(callerFilePath))!; var test = TestContext.CurrentContext.Test; var className = test.ClassName!.Substring(test.ClassName.LastIndexOf('.') + 1); var prefix = $"{className}.{test.MethodName}#"; @@ -80,6 +81,27 @@ public static void Verify(GeneratorDriver driver, [CallerFilePath] string caller Assert.That(failures, Is.Empty, "Generator output does not match snapshots"); } + /// + /// Maps a compiler-recorded source path back to the local file system. Deterministic + /// CI builds record source paths under /_/ rather than the real repository root. + /// + private static string ResolveSourcePath(string callerFilePath) + { + const string MappedRoot = "/_/"; + + if (File.Exists(callerFilePath) || !callerFilePath.StartsWith(MappedRoot, StringComparison.Ordinal)) + { + return callerFilePath; + } + + var repositoryRoot = typeof(GeneratorSnapshot).Assembly + .GetCustomAttributes() + .Single(a => a.Key == "RepositoryRoot") + .Value!; + + return Path.Combine(repositoryRoot, callerFilePath.Substring(MappedRoot.Length)); + } + private static string Normalize(string text) => text.TrimStart('').Replace("\r\n", "\n").TrimEnd(); } diff --git a/tests/SharpDicom.Tests/SharpDicom.Tests.csproj b/tests/SharpDicom.Tests/SharpDicom.Tests.csproj index 4bcd21a1..708f117c 100644 --- a/tests/SharpDicom.Tests/SharpDicom.Tests.csproj +++ b/tests/SharpDicom.Tests/SharpDicom.Tests.csproj @@ -31,4 +31,9 @@ + + + + +