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
88 changes: 88 additions & 0 deletions StringMatchesAssertion.patch
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
REQUIRED CHANGES TO: TUnit.Assertions/Conditions/StringAssertions.cs

This patch adds Match caching to StringMatchesAssertion class to enable clean, reflection-free access to regex match results.

================================================================================
CHANGE 1: Add _cachedMatch field
================================================================================
Location: Line 432 (after "private RegexOptions _options = RegexOptions.None;")

ADD THIS LINE:
private Match? _cachedMatch;

So it becomes:
private readonly string _pattern;
private readonly Regex? _regex;
private RegexOptions _options = RegexOptions.None;
private Match? _cachedMatch; // <-- ADD THIS LINE

================================================================================
CHANGE 2: Add GetMatch() method
================================================================================
Location: After line 464 (after the WithOptions() method, before CheckAsync())

ADD THIS METHOD:
/// <summary>
/// Gets the cached regex match result after the assertion has been executed.
/// Returns null if the assertion hasn't been executed yet or if the match failed.
/// </summary>
public Match? GetMatch() => _cachedMatch;

So it becomes:
public StringMatchesAssertion WithOptions(RegexOptions options)
{
_options = options;
Context.ExpressionBuilder.Append($".WithOptions({options})");
return this;
}

/// <summary>
/// Gets the cached regex match result after the assertion has been executed.
/// Returns null if the assertion hasn't been executed yet or if the match failed.
/// </summary>
public Match? GetMatch() => _cachedMatch; // <-- ADD THIS METHOD

protected override Task<AssertionResult> CheckAsync(EvaluationMetadata<string> metadata)
{

================================================================================
CHANGE 3: Modify CheckAsync() to cache the Match
================================================================================
Location: Lines 486-492 in CheckAsync() method

REPLACE THIS:
// Use the validated regex to check the match
bool isMatch = regex.IsMatch(value);

if (isMatch)
{
return Task.FromResult(AssertionResult.Passed);
}

WITH THIS:
// Use the validated regex to check the match and cache it
var match = regex.Match(value);
_cachedMatch = match;

if (match.Success)
{
return Task.FromResult(AssertionResult.Passed);
}

EXPLANATION:
- Changed from regex.IsMatch(value) to regex.Match(value) to get the Match object
- Store the Match in _cachedMatch field
- Changed from checking isMatch to checking match.Success

================================================================================
SUMMARY
================================================================================
These changes enable StringMatchesAssertionExtensions.GetMatchAsync() to work
without reflection by calling the new GetMatch() method.

The implementation follows SOLID, KISS, DRY, and SRP principles:
- Single Responsibility: StringMatchesAssertion now caches its match
- Open/Closed: API extended without modifying existing behavior
- No reflection: Clean, AOT-compatible code
- DRY: Match is performed once and cached
- KISS: Simple, straightforward implementation
72 changes: 72 additions & 0 deletions TUnit.Assertions.Tests/RegexAPITests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
using TUnit.Core;

namespace TUnit.Assertions.Tests;

public class RegexAPITests
{
[Test]
public async Task Test_Matches_WithGroup_DirectCall()
{
var email = "john.doe@example.com";
var pattern = @"(?<username>[\w.]+)@(?<domain>[\w.]+)";

// Test the new API - requires .And before .Group() for readability
await Assert.That(email)
.Matches(pattern)
.And.Group("username", user => user.IsEqualTo("john.doe"))
.And.Group("domain", domain => domain.IsEqualTo("example.com"));
}

[Test]
public async Task Test_Matches_WithAnd_ThenGroup()
{
var email = "john.doe@example.com";
var pattern = @"(?<username>[\w.]+)@(?<domain>[\w.]+)";

// Test with .And before first .Group()
await Assert.That(email)
.Matches(pattern)
.And.Group("username", user => user.IsEqualTo("john.doe"))
.And.Group("domain", domain => domain.IsEqualTo("example.com"));
}

[Test]
public async Task Test_Matches_WithMatchAt()
{
var text = "test123 hello456";
var pattern = @"\w+\d+";

// Test accessing multiple matches - requires .And before .Match()
await Assert.That(text)
.Matches(pattern)
.And.Match(0)
.And.Group(0, match => match.IsEqualTo("test123"));
}

[Test]
public async Task Test_Matches_IndexedGroups()
{
var date = "2025-10-28";
var pattern = @"(\d{4})-(\d{2})-(\d{2})";

// Test indexed groups - all require .And for consistency
await Assert.That(date)
.Matches(pattern)
.And.Group(0, full => full.IsEqualTo("2025-10-28"))
.And.Group(1, year => year.IsEqualTo("2025"))
.And.Group(2, month => month.IsEqualTo("10"))
.And.Group(3, day => day.IsEqualTo("28"));
}

[Test]
public async Task Test_Match_WithLambda()
{
var text = "test123 hello456";
var pattern = @"\w+\d+";

// Test lambda pattern for match assertions
await Assert.That(text)
.Matches(pattern)
.And.Match(0, match => match.Group(0, g => g.IsEqualTo("test123")));
}
}
156 changes: 156 additions & 0 deletions TUnit.Assertions/Assertions/Regex/GroupAssertion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
using TUnit.Assertions.Core;
using TUnit.Assertions.Sources;

namespace TUnit.Assertions.Assertions.Regex;

/// <summary>
/// Assertion for a regex capture group on a match collection (operates on first match).
/// </summary>
public class GroupAssertion : Assertion<RegexMatchCollection>
{
private readonly Func<ValueAssertion<string>, Assertion<string>?> _groupAssertion;
private readonly string? _groupName;
private readonly int? _groupIndex;

internal GroupAssertion(
AssertionContext<RegexMatchCollection> context,
string groupName,
Func<ValueAssertion<string>, Assertion<string>?> assertion,
bool _)
: base(context)
{
_groupName = groupName;
_groupAssertion = assertion;
}

internal GroupAssertion(
AssertionContext<RegexMatchCollection> context,
int groupIndex,
Func<ValueAssertion<string>, Assertion<string>?> assertion,
bool _)
: base(context)
{
_groupIndex = groupIndex;
_groupAssertion = assertion;
}

protected override async Task<AssertionResult> CheckAsync(EvaluationMetadata<RegexMatchCollection> metadata)
{
var collection = metadata.Value;
var exception = metadata.Exception;

if (exception != null)
{
return AssertionResult.Failed($"threw {exception.GetType().Name}: {exception.Message}");
}

if (collection == null || collection.Count == 0)
{
return AssertionResult.Failed("No match results available");
}

string groupValue;
try
{
groupValue = _groupName != null
? collection.First.GetGroup(_groupName)
: collection.First.GetGroup(_groupIndex!.Value);
}
catch (Exception ex)
{
return AssertionResult.Failed(ex.Message);
}

var groupAssertion = new ValueAssertion<string>(groupValue, _groupName ?? $"group {_groupIndex}");
var resultingAssertion = _groupAssertion(groupAssertion);
if (resultingAssertion != null)
{
await resultingAssertion.AssertAsync();
}
return AssertionResult.Passed;
}

protected override string GetExpectation()
{
if (_groupName != null)
{
return $"group '{_groupName}' to satisfy assertion";
}
return $"group {_groupIndex} to satisfy assertion";
}
}

/// <summary>
/// Assertion for a regex capture group on a specific match.
/// </summary>
public class MatchGroupAssertion : Assertion<RegexMatch>
{
private readonly Func<ValueAssertion<string>, Assertion<string>?> _groupAssertion;
private readonly string? _groupName;
private readonly int? _groupIndex;

internal MatchGroupAssertion(
AssertionContext<RegexMatch> context,
string groupName,
Func<ValueAssertion<string>, Assertion<string>?> assertion)
: base(context)
{
_groupName = groupName;
_groupAssertion = assertion;
}

internal MatchGroupAssertion(
AssertionContext<RegexMatch> context,
int groupIndex,
Func<ValueAssertion<string>, Assertion<string>?> assertion)
: base(context)
{
_groupIndex = groupIndex;
_groupAssertion = assertion;
}

protected override async Task<AssertionResult> CheckAsync(EvaluationMetadata<RegexMatch> metadata)
{
var match = metadata.Value;
var exception = metadata.Exception;

if (exception != null)
{
return AssertionResult.Failed($"threw {exception.GetType().Name}: {exception.Message}");
}

if (match == null)
{
return AssertionResult.Failed("No match available");
}

string groupValue;
try
{
groupValue = _groupName != null
? match.GetGroup(_groupName)
: match.GetGroup(_groupIndex!.Value);
}
catch (Exception ex)
{
return AssertionResult.Failed(ex.Message);
}

var groupAssertion = new ValueAssertion<string>(groupValue, _groupName ?? $"group {_groupIndex}");
var resultingAssertion = _groupAssertion(groupAssertion);
if (resultingAssertion != null)
{
await resultingAssertion.AssertAsync();
}
return AssertionResult.Passed;
}

protected override string GetExpectation()
{
if (_groupName != null)
{
return $"group '{_groupName}' to satisfy assertion";
}
return $"group {_groupIndex} to satisfy assertion";
}
}
68 changes: 68 additions & 0 deletions TUnit.Assertions/Assertions/Regex/MatchAssertion.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
using TUnit.Assertions.Core;
using TUnit.Assertions.Sources;

namespace TUnit.Assertions.Assertions.Regex;

/// <summary>
/// Assertion for a specific regex match with lambda support.
/// </summary>
public class MatchAssertion : Assertion<RegexMatchCollection>
{
private readonly int _index;
private readonly Func<ValueAssertion<RegexMatch>, Assertion<RegexMatch>?> _matchAssertion;

internal MatchAssertion(
AssertionContext<RegexMatchCollection> context,
int index,
Func<ValueAssertion<RegexMatch>, Assertion<RegexMatch>?> assertion)
: base(context)
{
_index = index;
_matchAssertion = assertion;
}

protected override async Task<AssertionResult> CheckAsync(EvaluationMetadata<RegexMatchCollection> metadata)
{
var collection = metadata.Value;
var exception = metadata.Exception;

if (exception != null)
{
return AssertionResult.Failed($"threw {exception.GetType().Name}: {exception.Message}");
}

if (collection == null)
{
return AssertionResult.Failed("No match collection available");
}

if (_index < 0 || _index >= collection.Count)
{
return AssertionResult.Failed(
$"Match index {_index} is out of range. Collection has {collection.Count} matches.");
}

RegexMatch match;
try
{
match = collection[_index];
}
catch (Exception ex)
{
return AssertionResult.Failed($"Failed to get match at index {_index}: {ex.Message}");
}

var matchAssertion = new ValueAssertion<RegexMatch>(match, $"match at index {_index}");
var resultingAssertion = _matchAssertion(matchAssertion);
if (resultingAssertion != null)
{
await resultingAssertion.AssertAsync();
}
return AssertionResult.Passed;
}

protected override string GetExpectation()
{
return $"match at index {_index} to satisfy assertion";
}
}
Loading
Loading