From ba115764d33b94942a3a3bf2a9abf78dfb7356ea Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Sat, 11 Jul 2026 18:48:31 +1000 Subject: [PATCH] Add ExcludeTargets with converter-side IsTargetExcluded check Converters that emit a source document (pdf, docx, xlsx, pptx) alongside the info file and derived renders force that document to be committed as a .verified file. ExcludeTargets drops every target of a given extension, keeping the rest of the snapshot. Exposed globally on VerifierSettings and per-verification on VerifySettings and SettingsTask. Applied at the single funnel all converters pass through, so it covers both stream and file converters. Excluded stream targets are disposed (the engine never receives them); excluding every target throws. Building a document can be expensive, so context.IsTargetExcluded(extension) lets a converter detect the exclusion and skip producing the target rather than have it dropped afterward. Per-verification exclusions are stored in Context so the converter can see them. --- docs/converter.md | 28 ++++++++++- docs/mdsource/converter.source.md | 9 ++++ src/Directory.Build.props | 2 +- ...verterObservesGlobalExclusion.verified.txt | 1 + .../ExcludeTargetsTests.cs | 20 ++++++++ ...KeepsTargetByDefault.verified.excludecheck | 1 + ...ConverterKeepsTargetByDefault.verified.txt | 1 + ....ConverterSkipsExcludedTarget.verified.txt | 1 + .../Converters/ExcludeTargetsTests.cs | 40 ++++++++++++++++ .../Splitters/Settings_ExcludeTargets.cs | 48 ++++++++++++++----- src/Verify/Verifier/InnerVerifier_Inner.cs | 4 +- src/Verify/VerifySettings.cs | 6 +-- 12 files changed, 139 insertions(+), 22 deletions(-) create mode 100644 src/StaticSettingsTests/ExcludeTargetsTests.ConverterObservesGlobalExclusion.verified.txt create mode 100644 src/Verify.Tests/Converters/ExcludeTargetsTests.ConverterKeepsTargetByDefault.verified.excludecheck create mode 100644 src/Verify.Tests/Converters/ExcludeTargetsTests.ConverterKeepsTargetByDefault.verified.txt create mode 100644 src/Verify.Tests/Converters/ExcludeTargetsTests.ConverterSkipsExcludedTarget.verified.txt diff --git a/docs/converter.md b/docs/converter.md index 86dad8427..2fd1ea136 100644 --- a/docs/converter.md +++ b/docs/converter.md @@ -220,7 +220,7 @@ public Task ExcludeConverterSourceTarget() => Verify(new MemoryStream("source-document"u8.ToArray()), "excludesource") .ExcludeTargets("excludesource"); ``` -snippet source | anchor +snippet source | anchor Any existing verified file for an excluded extension is then reported as pending deletion. @@ -243,6 +243,32 @@ public static class ModuleInitializer Excluding every target of a verification is an error, since a verification requires at least one target. +### Avoiding work in a converter + +Building the source document (for example rendering a pdf or a docx) can be expensive. When a target is excluded, it is dropped after the converter has produced it, so the work is wasted. A converter can instead check `IsTargetExcluded` on its `context` and skip producing the target entirely: + + + +```cs +// A converter that builds an expensive source document only when its target is kept. +static ConversionResult ConvertExcludeCheck(string? name, Stream stream, IReadOnlyDictionary context) +{ + List targets = [new("txt", "the rendered pages")]; + if (!context.IsTargetExcluded("excludecheck")) + { + // A real converter would build the document (eg render a pdf) here. + targets.Add(new("excludecheck", new MemoryStream("the source document"u8.ToArray()))); + } + + return new(null, targets); +} +``` +snippet source | anchor + + +`IsTargetExcluded` reflects both the global and the per-verification `ExcludeTargets`, so shipping this check lets a caller opt out of the document build itself, rather than only its snapshot. + + ## Shipping Converters can be shipped as NuGet packages: diff --git a/docs/mdsource/converter.source.md b/docs/mdsource/converter.source.md index 188d12388..0cce35eee 100644 --- a/docs/mdsource/converter.source.md +++ b/docs/mdsource/converter.source.md @@ -87,6 +87,15 @@ snippet: StaticExcludeTargets Excluding every target of a verification is an error, since a verification requires at least one target. +### Avoiding work in a converter + +Building the source document (for example rendering a pdf or a docx) can be expensive. When a target is excluded, it is dropped after the converter has produced it, so the work is wasted. A converter can instead check `IsTargetExcluded` on its `context` and skip producing the target entirely: + +snippet: ConverterExcludeCheck + +`IsTargetExcluded` reflects both the global and the per-verification `ExcludeTargets`, so shipping this check lets a caller opt out of the document build itself, rather than only its snapshot. + + ## Shipping Converters can be shipped as NuGet packages: diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 0c5bb1ec2..e28307389 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ CA1822;CS1591;CS0649;xUnit1026;xUnit1013;CS1573;VerifyTestsProjectDir;VerifySetParameters;PolyFillTargetsForNuget;xUnit1051;NU1608;NU1109 - 31.23.0 + 31.24.0 enable preview 1.0.0 diff --git a/src/StaticSettingsTests/ExcludeTargetsTests.ConverterObservesGlobalExclusion.verified.txt b/src/StaticSettingsTests/ExcludeTargetsTests.ConverterObservesGlobalExclusion.verified.txt new file mode 100644 index 000000000..d77edf693 --- /dev/null +++ b/src/StaticSettingsTests/ExcludeTargetsTests.ConverterObservesGlobalExclusion.verified.txt @@ -0,0 +1 @@ +pages \ No newline at end of file diff --git a/src/StaticSettingsTests/ExcludeTargetsTests.cs b/src/StaticSettingsTests/ExcludeTargetsTests.cs index 730482b46..4284ae87c 100644 --- a/src/StaticSettingsTests/ExcludeTargetsTests.cs +++ b/src/StaticSettingsTests/ExcludeTargetsTests.cs @@ -11,6 +11,26 @@ public async Task GlobalExclusionAppliesToAllVerifications() Assert.Single(result.Files); } + // A converter can see a global exclusion through its context, so it can skip the work. + [Fact] + public async Task ConverterObservesGlobalExclusion() + { + var sawExclusion = false; + VerifierSettings.RegisterStreamConverter( + "globalexcludecheck", + (_, _, context) => + { + sawExclusion = context.IsTargetExcluded("globalexcludecheck"); + return new(null, [new("txt", "pages")]); + }); + VerifierSettings.ExcludeTargets("globalexcludecheck"); + + await Verify(new MemoryStream("input"u8.ToArray()), "globalexcludecheck") + .DisableDiff(); + + Assert.True(sawExclusion); + } + [Fact] public void AfterVerifyHasBeenRunThrows() { diff --git a/src/Verify.Tests/Converters/ExcludeTargetsTests.ConverterKeepsTargetByDefault.verified.excludecheck b/src/Verify.Tests/Converters/ExcludeTargetsTests.ConverterKeepsTargetByDefault.verified.excludecheck new file mode 100644 index 000000000..cccf6de19 --- /dev/null +++ b/src/Verify.Tests/Converters/ExcludeTargetsTests.ConverterKeepsTargetByDefault.verified.excludecheck @@ -0,0 +1 @@ +the source document \ No newline at end of file diff --git a/src/Verify.Tests/Converters/ExcludeTargetsTests.ConverterKeepsTargetByDefault.verified.txt b/src/Verify.Tests/Converters/ExcludeTargetsTests.ConverterKeepsTargetByDefault.verified.txt new file mode 100644 index 000000000..92575dab0 --- /dev/null +++ b/src/Verify.Tests/Converters/ExcludeTargetsTests.ConverterKeepsTargetByDefault.verified.txt @@ -0,0 +1 @@ +the rendered pages \ No newline at end of file diff --git a/src/Verify.Tests/Converters/ExcludeTargetsTests.ConverterSkipsExcludedTarget.verified.txt b/src/Verify.Tests/Converters/ExcludeTargetsTests.ConverterSkipsExcludedTarget.verified.txt new file mode 100644 index 000000000..92575dab0 --- /dev/null +++ b/src/Verify.Tests/Converters/ExcludeTargetsTests.ConverterSkipsExcludedTarget.verified.txt @@ -0,0 +1 @@ +the rendered pages \ No newline at end of file diff --git a/src/Verify.Tests/Converters/ExcludeTargetsTests.cs b/src/Verify.Tests/Converters/ExcludeTargetsTests.cs index fb49a206f..ae4481bb7 100644 --- a/src/Verify.Tests/Converters/ExcludeTargetsTests.cs +++ b/src/Verify.Tests/Converters/ExcludeTargetsTests.cs @@ -14,6 +14,27 @@ public static void Init() => new("txt", "derived from source") ])); + [ModuleInitializer] + public static void ExcludeCheckInit() => + VerifierSettings.RegisterStreamConverter("excludecheck", ConvertExcludeCheck); + + #region ConverterExcludeCheck + + // A converter that builds an expensive source document only when its target is kept. + static ConversionResult ConvertExcludeCheck(string? name, Stream stream, IReadOnlyDictionary context) + { + List targets = [new("txt", "the rendered pages")]; + if (!context.IsTargetExcluded("excludecheck")) + { + // A real converter would build the document (eg render a pdf) here. + targets.Add(new("excludecheck", new MemoryStream("the source document"u8.ToArray()))); + } + + return new(null, targets); + } + + #endregion + class SourceDocument; // The same shape, reached through a file converter rather than a stream converter. @@ -82,6 +103,25 @@ public async Task ExcludeFileConverterSourceTarget() Assert.Single(result.Files); } + // The converter observes the exclusion and never builds the source document. + [Fact] + public async Task ConverterSkipsExcludedTarget() + { + var result = await Verify(new MemoryStream("input"u8.ToArray()), "excludecheck") + .ExcludeTargets("excludecheck") + .DisableDiff(); + Assert.Single(result.Files); + } + + // Without an exclusion the converter builds and keeps the source document. + [Fact] + public async Task ConverterKeepsTargetByDefault() + { + var result = await Verify(new MemoryStream("input"u8.ToArray()), "excludecheck") + .DisableDiff(); + Assert.Equal(2, result.Files.Count()); + } + [Fact] public async Task ExcludingAllTargetsThrows() { diff --git a/src/Verify/Splitters/Settings_ExcludeTargets.cs b/src/Verify/Splitters/Settings_ExcludeTargets.cs index a4bb91b6b..0990adb19 100644 --- a/src/Verify/Splitters/Settings_ExcludeTargets.cs +++ b/src/Verify/Splitters/Settings_ExcludeTargets.cs @@ -13,38 +13,57 @@ public static partial class VerifierSettings public static void ExcludeTargets(params string[] extensions) { InnerVerifier.ThrowIfVerifyHasBeenRun(); - excludedTargets = AddExcludedTargets(excludedTargets, extensions); + excludedTargets = AddExtensions(excludedTargets, extensions); } - internal static bool AnyExcludedTargets(VerifySettings settings) => - excludedTargets != null || - settings.excludedTargets != null; + /// + /// Whether a target with will be excluded from the current + /// verification, via either the global or the per-verification ExcludeTargets. + /// A converter can call this on its context to skip producing an expensive target + /// (eg rendering a document) that would otherwise be dropped. + /// + public static bool IsTargetExcluded(this IReadOnlyDictionary context, string extension) + { + Guards.AgainstBadExtension(extension); + return IsExcluded(context, extension); + } - internal static bool IsTargetExcluded(VerifySettings settings, string extension) => + internal static bool IsExcluded(IReadOnlyDictionary context, string extension) => excludedTargets?.Contains(extension) == true || - settings.excludedTargets?.Contains(extension) == true; + (context.TryGetValue(VerifySettings.excludedTargetsKey, out var value) && + ((HashSet) value).Contains(extension)); - internal static HashSet AddExcludedTargets(HashSet? existing, string[] extensions) + internal static bool AnyExcludedTargets(IReadOnlyDictionary context) => + excludedTargets is { Count: > 0 } || + context.ContainsKey(VerifySettings.excludedTargetsKey); + + // Builds a fresh set rather than mutating existing, so a set shared with cloned settings + // (Context values are copied by reference) is never mutated in place. + internal static HashSet AddExtensions(HashSet? existing, string[] extensions) { if (extensions.Length == 0) { throw new ArgumentException("At least one extension is required.", nameof(extensions)); } - existing ??= new(StringComparer.OrdinalIgnoreCase); + var result = existing == null ? + new(StringComparer.OrdinalIgnoreCase) : + new HashSet(existing, StringComparer.OrdinalIgnoreCase); foreach (var extension in extensions) { Guards.AgainstBadExtension(extension); - existing.Add(extension); + result.Add(extension); } - return existing; + return result; } } public partial class VerifySettings { - internal HashSet? excludedTargets; + // The per-verification excluded set lives in Context so that a converter, which receives + // Context, can consult it via IsTargetExcluded. + internal const string excludedTargetsKey = "Verify.ExcludeTargets"; /// /// Excludes every target with one of from the current verification. @@ -52,8 +71,11 @@ public partial class VerifySettings /// Intended for converters that emit a source document (eg pdf or docx) alongside /// the info file and rendered pages, where committing the source document is not wanted. /// - public void ExcludeTargets(params string[] extensions) => - excludedTargets = VerifierSettings.AddExcludedTargets(excludedTargets, extensions); + public void ExcludeTargets(params string[] extensions) + { + var existing = Context.TryGetValue(excludedTargetsKey, out var value) ? (HashSet) value : null; + Context[excludedTargetsKey] = VerifierSettings.AddExtensions(existing, extensions); + } } public partial class SettingsTask diff --git a/src/Verify/Verifier/InnerVerifier_Inner.cs b/src/Verify/Verifier/InnerVerifier_Inner.cs index eb0c2712c..b3ce46f7e 100644 --- a/src/Verify/Verifier/InnerVerifier_Inner.cs +++ b/src/Verify/Verifier/InnerVerifier_Inner.cs @@ -59,7 +59,7 @@ async Task VerifyInner(object? root, Func? cleanup, IEnumera Func RemoveExcludedTargets(List targets, Func cleanup, out bool removed) { removed = false; - if (!VerifierSettings.AnyExcludedTargets(settings)) + if (!VerifierSettings.AnyExcludedTargets(settings.Context)) { return cleanup; } @@ -67,7 +67,7 @@ Func RemoveExcludedTargets(List targets, Func cleanup, out b for (var index = targets.Count - 1; index >= 0; index--) { var target = targets[index]; - if (!VerifierSettings.IsTargetExcluded(settings, target.Extension)) + if (!VerifierSettings.IsExcluded(settings.Context, target.Extension)) { continue; } diff --git a/src/Verify/VerifySettings.cs b/src/Verify/VerifySettings.cs index 19c8f964f..e20f60181 100644 --- a/src/Verify/VerifySettings.cs +++ b/src/Verify/VerifySettings.cs @@ -44,11 +44,7 @@ public VerifySettings(VerifySettings? settings) // (AppendFile/UseStreamComparer/etc.) would mutate a shared base settings // instance and leak across tests. appendedFiles = settings.appendedFiles?.ToList(); - if (settings.excludedTargets != null) - { - excludedTargets = new(settings.excludedTargets, StringComparer.OrdinalIgnoreCase); - } - + // excludedTargets lives in Context (see ExcludeTargets), so it is cloned by the Context copy below UniqueDirectory = settings.UniqueDirectory; Directory = settings.Directory; autoVerify = settings.autoVerify;