Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 27 additions & 1 deletion docs/converter.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ public Task ExcludeConverterSourceTarget() =>
Verify(new MemoryStream("source-document"u8.ToArray()), "excludesource")
.ExcludeTargets("excludesource");
```
<sup><a href='/src/Verify.Tests/Converters/ExcludeTargetsTests.cs#L67-L74' title='Snippet source file'>snippet source</a> | <a href='#snippet-ExcludeTargets' title='Start of snippet'>anchor</a></sup>
<sup><a href='/src/Verify.Tests/Converters/ExcludeTargetsTests.cs#L88-L95' title='Snippet source file'>snippet source</a> | <a href='#snippet-ExcludeTargets' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

Any existing verified file for an excluded extension is then reported as pending deletion.
Expand All @@ -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:

<!-- snippet: ConverterExcludeCheck -->
<a id='snippet-ConverterExcludeCheck'></a>
```cs
// A converter that builds an expensive source document only when its target is kept.
static ConversionResult ConvertExcludeCheck(string? name, Stream stream, IReadOnlyDictionary<string, object> context)
{
List<Target> 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);
}
```
<sup><a href='/src/Verify.Tests/Converters/ExcludeTargetsTests.cs#L21-L36' title='Snippet source file'>snippet source</a> | <a href='#snippet-ConverterExcludeCheck' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

`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:
Expand Down
9 changes: 9 additions & 0 deletions docs/mdsource/converter.source.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<Project>
<PropertyGroup>
<NoWarn>CA1822;CS1591;CS0649;xUnit1026;xUnit1013;CS1573;VerifyTestsProjectDir;VerifySetParameters;PolyFillTargetsForNuget;xUnit1051;NU1608;NU1109</NoWarn>
<Version>31.23.0</Version>
<Version>31.24.0</Version>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>preview</LangVersion>
<AssemblyVersion>1.0.0</AssemblyVersion>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
pages
20 changes: 20 additions & 0 deletions src/StaticSettingsTests/ExcludeTargetsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
the source document
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
the rendered pages
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
the rendered pages
40 changes: 40 additions & 0 deletions src/Verify.Tests/Converters/ExcludeTargetsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, object> context)
{
List<Target> 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.
Expand Down Expand Up @@ -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()
{
Expand Down
48 changes: 35 additions & 13 deletions src/Verify/Splitters/Settings_ExcludeTargets.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,47 +13,69 @@ 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;
/// <summary>
/// Whether a target with <paramref name="extension" /> will be excluded from the current
/// verification, via either the global or the per-verification <c>ExcludeTargets</c>.
/// A converter can call this on its <c>context</c> to skip producing an expensive target
/// (eg rendering a document) that would otherwise be dropped.
/// </summary>
public static bool IsTargetExcluded(this IReadOnlyDictionary<string, object> context, string extension)
{
Guards.AgainstBadExtension(extension);
return IsExcluded(context, extension);
}

internal static bool IsTargetExcluded(VerifySettings settings, string extension) =>
internal static bool IsExcluded(IReadOnlyDictionary<string, object> context, string extension) =>
excludedTargets?.Contains(extension) == true ||
settings.excludedTargets?.Contains(extension) == true;
(context.TryGetValue(VerifySettings.excludedTargetsKey, out var value) &&
((HashSet<string>) value).Contains(extension));

internal static HashSet<string> AddExcludedTargets(HashSet<string>? existing, string[] extensions)
internal static bool AnyExcludedTargets(IReadOnlyDictionary<string, object> 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<string> AddExtensions(HashSet<string>? 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<string>(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<string>? 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";

/// <summary>
/// Excludes every target with one of <paramref name="extensions" /> from the current verification.
/// Any existing verified file for an excluded extension is treated as pending deletion.
/// Intended for converters that emit a source document (eg <c>pdf</c> or <c>docx</c>) alongside
/// the info file and rendered pages, where committing the source document is not wanted.
/// </summary>
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<string>) value : null;
Context[excludedTargetsKey] = VerifierSettings.AddExtensions(existing, extensions);
}
}

public partial class SettingsTask
Expand Down
4 changes: 2 additions & 2 deletions src/Verify/Verifier/InnerVerifier_Inner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,15 @@ async Task<VerifyResult> VerifyInner(object? root, Func<Task>? cleanup, IEnumera
Func<Task> RemoveExcludedTargets(List<Target> targets, Func<Task> cleanup, out bool removed)
{
removed = false;
if (!VerifierSettings.AnyExcludedTargets(settings))
if (!VerifierSettings.AnyExcludedTargets(settings.Context))
{
return cleanup;
}

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;
}
Expand Down
6 changes: 1 addition & 5 deletions src/Verify/VerifySettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading