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
29 changes: 29 additions & 0 deletions docs/comparer.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,35 @@ The returned `CompareResult.NotEqual` takes an optional message that will be ren
**If an input is split into multiple files, and a text file fails, then all subsequent binary comparisons will revert to the default comparison.**


### Bypass comparers for derived targets

When a converter splits an input into multiple targets, for example a source document plus derived outputs such as rendered images or extracted text, a lenient comparer on a derived target can mask a real change in the source. Setting `BypassComparersForSubsequentOnDifference` on the source target ensures that, when the source differs from its verified file, all subsequent targets skip their registered comparers and fall back to exact comparison:

<!-- snippet: BypassComparersForSubsequentOnDifference -->
<a id='snippet-BypassComparersForSubsequentOnDifference'></a>
```cs
// A converter that emits a canonical source document alongside derived targets (eg rendered pages).
// The source is flagged so that, when it differs, the derived targets skip their (potentially lenient)
// comparers and fall back to exact comparison, ensuring a real change in the source is never masked.
public static ConversionResult ConvertDocument(Stream document, IReadOnlyDictionary<string, object> context)
{
Target[] targets =
[
new("docx", document)
{
BypassComparersForSubsequentOnDifference = true
},
new("png", RenderPage(document))
];
return new(info: null, targets);
}
```
<sup><a href='/src/Verify.Tests/Snippets/BypassComparerSnippets.cs#L5-L23' title='Snippet source file'>snippet source</a> | <a href='#snippet-BypassComparersForSubsequentOnDifference' title='Start of snippet'>anchor</a></sup>
<!-- endSnippet -->

The flag must be set on the source target, and that target must precede the derived targets in the conversion result.


### Instance comparer

<!-- snippet: InstanceComparer -->
Expand Down
9 changes: 9 additions & 0 deletions docs/mdsource/comparer.source.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ The returned `CompareResult.NotEqual` takes an optional message that will be ren
**If an input is split into multiple files, and a text file fails, then all subsequent binary comparisons will revert to the default comparison.**


### Bypass comparers for derived targets

When a converter splits an input into multiple targets, for example a source document plus derived outputs such as rendered images or extracted text, a lenient comparer on a derived target can mask a real change in the source. Setting `BypassComparersForSubsequentOnDifference` on the source target ensures that, when the source differs from its verified file, all subsequent targets skip their registered comparers and fall back to exact comparison:

snippet: BypassComparersForSubsequentOnDifference

The flag must be set on the source target, and that target must precede the derived targets in the conversion result.


### Instance comparer

snippet: InstanceComparer
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
derived-other
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
source-other
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
null
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
derived-other
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
source-content
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
null
48 changes: 48 additions & 0 deletions src/Verify.Tests/Comparer/BypassComparerTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
public class BypassComparerTests
{
// Mimics a converter that emits a canonical source target (eg a document) alongside a derived
// target (eg a rendered image) whose comparer can mask a real difference in the source.
static Target[] BuildTargets() =>
[
new("bypasssource", new MemoryStream("source-content"u8.ToArray()))
{
BypassComparersForSubsequentOnDifference = true
},
new("bypassderived", new MemoryStream("derived-content"u8.ToArray()))
];

static int derivedCompareCount;

// A comparer that masks every difference by always reporting equal.
static Task<CompareResult> MaskingDerivedComparer(Stream received, Stream verified, IReadOnlyDictionary<string, object> context)
{
derivedCompareCount++;
return Task.FromResult(CompareResult.Equal);
}

[Fact]
public async Task SourceEqual_DerivedComparerUsed()
{
derivedCompareCount = 0;
// The source matches its verified file, so no bypass is triggered and the derived comparer
// runs and masks the difference in the derived target.
await Verify(null, BuildTargets())
.UseStreamComparer(MaskingDerivedComparer, "bypassderived")
.DisableDiff();
Assert.Equal(1, derivedCompareCount);
}

[Fact]
public async Task SourceDiffers_DerivedComparerBypassed()
{
derivedCompareCount = 0;
// The source differs from its verified file. Because it is flagged, the derived comparer is
// bypassed (never invoked) and the otherwise-masked derived difference is surfaced.
var exception = await Assert.ThrowsAsync<VerifyException>(
() => Verify(null, BuildTargets())
.UseStreamComparer(MaskingDerivedComparer, "bypassderived")
.DisableDiff());
Assert.Equal(0, derivedCompareCount);
Assert.Contains("bypassderived", exception.Message);
}
}
29 changes: 29 additions & 0 deletions src/Verify.Tests/Snippets/BypassComparerSnippets.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#if DEBUG

public class BypassComparerSnippets
{
#region BypassComparersForSubsequentOnDifference

// A converter that emits a canonical source document alongside derived targets (eg rendered pages).
// The source is flagged so that, when it differs, the derived targets skip their (potentially lenient)
// comparers and fall back to exact comparison, ensuring a real change in the source is never masked.
public static ConversionResult ConvertDocument(Stream document, IReadOnlyDictionary<string, object> context)
{
Target[] targets =
[
new("docx", document)
{
BypassComparersForSubsequentOnDifference = true
},
new("png", RenderPage(document))
];
return new(info: null, targets);
}

#endregion

static Stream RenderPage(Stream document) =>
new MemoryStream();
}

#endif
8 changes: 5 additions & 3 deletions src/Verify/Compare/Comparer.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
static class Comparer
{
public static async Task<EqualityResult> Text(FilePair filePair, StringBuilder received, VerifySettings settings)
public static async Task<EqualityResult> Text(FilePair filePair, StringBuilder received, VerifySettings settings, bool bypassComparer = false)
{
IoHelpers.DeleteFileIfEmpty(filePair.VerifiedPath);
if (!File.Exists(filePair.VerifiedPath))
Expand All @@ -10,7 +10,7 @@ public static async Task<EqualityResult> Text(FilePair filePair, StringBuilder r
}

var verified = await IoHelpers.ReadStringBuilderWithFixedLines(filePair.VerifiedPath);
var result = await CompareStrings(filePair.Extension, received, verified, settings);
var result = await CompareStrings(filePair.Extension, received, verified, settings, bypassComparer);
if (result.IsEqual)
{
return new(Equality.Equal, null, received, verified);
Expand All @@ -20,7 +20,7 @@ public static async Task<EqualityResult> Text(FilePair filePair, StringBuilder r
return new(Equality.NotEqual, result.Message, received, verified);
}

static Task<CompareResult> CompareStrings(string extension, StringBuilder received, StringBuilder verified, VerifySettings settings)
static Task<CompareResult> CompareStrings(string extension, StringBuilder received, StringBuilder verified, VerifySettings settings, bool bypassComparer)
{
if (verified.Length > 0 &&
verified.Length - 1 == received.Length &&
Expand All @@ -33,6 +33,7 @@ static Task<CompareResult> CompareStrings(string extension, StringBuilder receiv
#if NET6_0_OR_GREATER
var isEqual = verified.Equals(received);
if (!isEqual &&
!bypassComparer &&
settings.TryFindStringComparer(extension, out var compare))
{
return compare(received.ToString(), verified.ToString(), settings.Context);
Expand All @@ -42,6 +43,7 @@ static Task<CompareResult> CompareStrings(string extension, StringBuilder receiv
var verifiedString = verified.ToString();
var isEqual = receivedString.Equals(verifiedString);
if (!isEqual &&
!bypassComparer &&
settings.TryFindStringComparer(extension, out var compare))
{
return compare(receivedString, verifiedString, settings.Context);
Expand Down
4 changes: 2 additions & 2 deletions src/Verify/Compare/FileComparer.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
static class FileComparer
{
public static async Task<EqualityResult> DoCompare(VerifySettings settings, FilePair file, bool previousTextFailed, Stream receivedStream)
public static async Task<EqualityResult> DoCompare(VerifySettings settings, FilePair file, bool bypassComparer, Stream receivedStream)
{
if (!File.Exists(file.VerifiedPath))
{
Expand All @@ -14,7 +14,7 @@ public static async Task<EqualityResult> DoCompare(VerifySettings settings, File
return new(Equality.NotEqual, null, null, null);
}

if (!previousTextFailed &&
if (!bypassComparer &&
settings.TryFindStreamComparer(file.Extension, out var compare))
{
return await InnerCompare(file, receivedStream, compare, settings.Context);
Expand Down
10 changes: 10 additions & 0 deletions src/Verify/Target.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ public readonly struct Target
public string Extension { get; }
public string? Name { get; } = null;
public bool PerformConversion { get; } = true;

/// <summary>
/// When <c>true</c> and this target differs from its verified file, all subsequent targets in the
/// same verification skip their registered comparers and fall back to exact (binary or string) comparison.
/// Intended for converters that emit a canonical source target (eg a document) alongside derived targets
/// (eg rendered images or extracted text) whose comparers may otherwise mask a real difference in the source.
/// Set this on the source target, and ensure it precedes the derived targets in the conversion result.
/// </summary>
public bool BypassComparersForSubsequentOnDifference { get; init; }

public string NameOrTarget => Name ?? "target";

public Stream StreamData
Expand Down
24 changes: 16 additions & 8 deletions src/Verify/Verifier/VerifyEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,18 @@ class VerifyEngine(
public IReadOnlyList<FilePair> Equal => equal;
public IReadOnlyList<FilePair> AutoVerified => autoVerified;

static async Task<EqualityResult> GetResult(VerifySettings settings, FilePair file, Target target, bool previousTextFailed)
static async Task<EqualityResult> GetResult(VerifySettings settings, FilePair file, Target target, bool textHasFailed, bool bypassComparers)
{
try
{
if (target.TryGetStringBuilder(out var value))
{
return await Comparer.Text(file, value, settings);
return await Comparer.Text(file, value, settings, bypassComparers);
}

using var stream = target.StreamData;
stream.MoveToStart();
return await FileComparer.DoCompare(settings, file, previousTextFailed, stream);
return await FileComparer.DoCompare(settings, file, textHasFailed || bypassComparers, stream);
}
catch (Exception exception)
{
Expand All @@ -54,21 +54,29 @@ public async Task HandleResults(List<Target> targetList)
{
var target = targetList[0];
var file = getFileNames(target);
var result = await GetResult(settings, file, target, false);
var result = await GetResult(settings, file, target, false, false);
HandleCompareResult(result, file);
return;
}

var textHasFailed = false;
var bypassComparers = false;

async Task Inner(FilePair file, Target target)
{
var result = await GetResult(settings, file, target, textHasFailed);
var result = await GetResult(settings, file, target, textHasFailed, bypassComparers);

if (file.IsText &&
result.Equality != Equality.Equal)
if (result.Equality != Equality.Equal)
{
textHasFailed = true;
if (file.IsText)
{
textHasFailed = true;
}

if (target.BypassComparersForSubsequentOnDifference)
{
bypassComparers = true;
}
}

HandleCompareResult(result, file);
Expand Down
Loading