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
48 changes: 35 additions & 13 deletions Source/aweXpect.Core/Core/Nodes/WhichNode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,28 +90,45 @@ public override async Task<ConstraintResult> IsMetBy<TValue>(
FurtherProcessingStrategy.IgnoreResult, default);
}

if (value is TSource typedValue)
TSource? source = ResolveSource(parentResult, value);
TMember? matchingValue = await ComputeMatchingValueAsync(source);

ConstraintResult innerResult = await _inner.IsMetBy(matchingValue, context, cancellationToken);
return CombineResults(parentResult, innerResult, _separator ?? "", FurtherProcessingStrategy.IgnoreResult,
matchingValue);
}

private static TSource? ResolveSource(ConstraintResult? parentResult, object value)
{
if (parentResult != null && parentResult.TryGetValue(out TSource? projectedValue))
{
TMember? matchingValue;
if (_memberAccessor != null)
{
matchingValue = _memberAccessor(typedValue);
}
else
{
matchingValue = await _asyncMemberAccessor!.Invoke(typedValue);
}
return projectedValue;
Comment thread
vbreuss marked this conversation as resolved.
}

ConstraintResult result = await _inner.IsMetBy(matchingValue, context, cancellationToken);
return CombineResults(parentResult, result, _separator ?? "", FurtherProcessingStrategy.IgnoreResult,
matchingValue);
if (value is TSource directValue)
{
return directValue;
}

throw new InvalidOperationException(
$"The member type for the actual value in the which node did not match.{Environment.NewLine} Found: {Formatter.Format(value.GetType())}{Environment.NewLine} Expected: {Formatter.Format(typeof(TSource))}")
.LogTrace();
}

private async Task<TMember?> ComputeMatchingValueAsync(TSource? source)
{
#pragma warning disable S2583
if (source is null)
{
return default;
}
#pragma warning restore S2583

return _memberAccessor != null
? _memberAccessor(source)
: await _asyncMemberAccessor!.Invoke(source);
}

/// <inheritdoc cref="object.Equals(object?)" />
public override bool Equals(object? obj) => obj is WhichNode<TSource, TMember> other && Equals(other);

Expand Down Expand Up @@ -225,6 +242,11 @@ public override bool TryGetValue<TValue>([NotNullWhen(true)] out TValue? value)
}

value = default;
// When neither this result nor its sub-chains carry a TValue, fall through to a
// type-compatibility check so chained WhichNodes can keep propagating projections
// even when the recorded matching value happens to be null (e.g. an outer
// `Which(p => p.Address)` projecting `null`). The caller is expected to guard
// against null `value` even though `[NotNullWhen(true)]` is annotated on the base.
return typeof(TValue).IsAssignableFrom(typeof(TMember));
}

Expand Down
64 changes: 61 additions & 3 deletions Tests/aweXpect.Core.Tests/Core/ExpectationBuilderTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Threading;
using System.Globalization;
using System.Threading;
using aweXpect.Core.Constraints;
using aweXpect.Core.Tests.TestHelpers;

Expand Down Expand Up @@ -148,13 +149,52 @@ public async Task ForWhich_Async_CalledTwice_ShouldHonorConstraintsFromAllLevels
sut.ForWhich(upperAccessor, " whose upper ");
sut.AddConstraint((_, _) => new DummyConstraint<string>(s => s == "FOO", "is FOO"));
sut.ForWhich(doubledAccessor, " and whose doubled ");
sut.AddConstraint((_, _) => new DummyConstraint<string>(s => s == "foofoo", "is foofoo"));
sut.AddConstraint((_, _) => new DummyConstraint<string>(s => s == "FOOFOO", "is FOOFOO"));
Comment thread
vbreuss marked this conversation as resolved.

ConstraintResult result = await sut.IsMetBy("foo", null!, CancellationToken.None);

await That(result.Outcome).IsEqualTo(Outcome.Success);
await That(result.GetExpectationText())
.IsEqualTo("is foo whose upper is FOO and whose doubled is foofoo");
.IsEqualTo("is foo whose upper is FOO and whose doubled is FOOFOO");
}

[Fact]
public async Task ForWhich_Async_CalledTwice_WhereSecondProjectsFromFirstResult_ShouldChainProjections()
{
Func<int, Task<string?>> stringify = i =>
Task.FromResult<string?>(i.ToString(CultureInfo.InvariantCulture));
Func<string, Task<string?>> doubled = s => Task.FromResult<string?>(s + s);
ManualExpectationBuilder<int> sut = new(null);
sut.AddConstraint((_, _) => new DummyConstraint<int>(i => i == 12, "is 12"));
sut.ForWhich(stringify, " whose string ");
sut.AddConstraint((_, _) => new DummyConstraint<string>(s => s == "12", "is \"12\""));
sut.ForWhich(doubled, " and whose doubled ");
sut.AddConstraint((_, _) => new DummyConstraint<string>(s => s == "1212", "is \"1212\""));

ConstraintResult result = await sut.IsMetBy(12, null!, CancellationToken.None);

await That(result.Outcome).IsEqualTo(Outcome.Success);
await That(result.GetExpectationText())
.IsEqualTo("is 12 whose string is \"12\" and whose doubled is \"1212\"");
}

[Fact]
public async Task ForWhich_CalledThreeTimes_EachProjectionChainsFromPrevious_ShouldEvaluateDeeply()
{
ManualExpectationBuilder<string> sut = new(null);
sut.AddConstraint((_, _) => new DummyConstraint<string>(s => s == "foo", "is foo"));
sut.ForWhich<string, char>(s => s[0], " whose first char ");
sut.AddConstraint((_, _) => new DummyConstraint<char>(c => c == 'f', "is 'f'"));
sut.ForWhich<char, int>(c => c, " whose code point ");
sut.AddConstraint((_, _) => new DummyConstraint<int>(i => i == 'f', "is 102"));
sut.ForWhich<int, bool>(i => i % 2 == 0, " whose is-even ");
sut.AddConstraint((_, _) => new DummyConstraint<bool>(b => b, "is true"));

ConstraintResult result = await sut.IsMetBy("foo", null!, CancellationToken.None);

await That(result.Outcome).IsEqualTo(Outcome.Success);
await That(result.GetExpectationText())
.IsEqualTo("is foo whose first char is 'f' whose code point is 102 whose is-even is true");
}

[Fact]
Expand Down Expand Up @@ -208,6 +248,24 @@ await That(result.GetExpectationText())
.IsEqualTo("is foo whose length is 3 and whose first char is 'f'");
}

[Fact]
public async Task ForWhich_CalledTwice_WhereSecondProjectsFromFirstResult_ShouldChainProjections()
{
ManualExpectationBuilder<int> sut = new(null);
sut.AddConstraint((_, _) => new DummyConstraint<int>(i => i == 123, "is 123"));
sut.ForWhich<int, string>(i => i.ToString(CultureInfo.InvariantCulture),
" whose string ");
sut.AddConstraint((_, _) => new DummyConstraint<string>(s => s == "123", "is \"123\""));
sut.ForWhich<string, int>(s => s.Length, " and whose length ");
sut.AddConstraint((_, _) => new DummyConstraint<int>(i => i == 3, "is 3"));

ConstraintResult result = await sut.IsMetBy(123, null!, CancellationToken.None);

await That(result.Outcome).IsEqualTo(Outcome.Success);
await That(result.GetExpectationText())
.IsEqualTo("is 123 whose string is \"123\" and whose length is 3");
}

[Fact]
public async Task WhenSubjectHasMultipleLines_ShouldTrimCommonWhiteSpace()
{
Expand Down
73 changes: 70 additions & 3 deletions Tests/aweXpect.Core.Tests/Core/Nodes/WhichNodeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ public async Task IsMetBy_EmptyExpectationNode_ShouldThrowInvalidOperationExcept
Task<ConstraintResult> Act() => whichNode.IsMetBy("foo", null!, CancellationToken.None);

await That(Act).Throws<InvalidOperationException>()
.WithMessage("The expectation node does not support int with value 3");
.WithMessage("The expectation node does not support int with value 1");
Comment thread
vbreuss marked this conversation as resolved.
}

[Fact]
Expand All @@ -280,10 +280,77 @@ public async Task IsMetBy_ExpectationNodeWithConstraint_ShouldApplyConstraint()
await That(sb.ToString()).IsEqualTo("e1e2");
}

[Fact]
public async Task IsMetBy_WhenOuterTypeDoesNotMatchButParentExposesProjectedValue_ShouldFallBackToParentProjection()
{
DummyNode node1 = new("", () => new DummyConstraintResult<string?>(Outcome.Success, "abcd", ""));
WhichNode<string, int> whichNode = new(node1, s => s.Length);
whichNode.AddNode(new ExpectationNode());
int? observed = null;
whichNode.AddConstraint(new DummyConstraint<int>(i =>
{
observed = i;
return true;
}, "is anything"));

ConstraintResult result = await whichNode.IsMetBy(DateTime.Now, null!, CancellationToken.None);

await That(result.Outcome).IsEqualTo(Outcome.Success);
await That(observed).IsEqualTo(4);
}

[Fact]
public async Task IsMetBy_WhenParentChainProjectsThroughThreeLevels_ShouldPropagateInnermostValue()
{
WhichNode<string, char> level1 = new(null, s => s![0]);
level1.AddNode(new ExpectationNode());
level1.AddConstraint(new DummyConstraint<char>(_ => true, "first"));

WhichNode<char, int> level2 = new(level1, c => c);
level2.AddNode(new ExpectationNode());
level2.AddConstraint(new DummyConstraint<int>(_ => true, "code"));

WhichNode<int, bool> level3 = new(level2, i => i % 2 == 0);
level3.AddNode(new ExpectationNode());
bool? observed = null;
level3.AddConstraint(new DummyConstraint<bool>(b =>
{
observed = b;
return b;
}, "is true"));

ConstraintResult result = await level3.IsMetBy("foo", null!, CancellationToken.None);

await That(result.Outcome).IsEqualTo(Outcome.Success);
await That(observed).IsEqualTo(true);
}

[Fact]
public async Task IsMetBy_WhenParentWhichNodeProjectsToInnerSource_ShouldEvaluateAgainstProjectedValue()
{
WhichNode<string, int> outerWhich = new(null, s => s!.Length);
outerWhich.AddNode(new ExpectationNode());
outerWhich.AddConstraint(new DummyConstraint<int>(_ => true, "length"));

bool? observed = null;
WhichNode<int, bool> innerWhich = new(outerWhich, i => i % 2 == 0);
innerWhich.AddNode(new ExpectationNode());
innerWhich.AddConstraint(new DummyConstraint<bool>(b =>
{
observed = b;
return b;
}, "is true"));

ConstraintResult result = await innerWhich.IsMetBy("food", null!, CancellationToken.None);

await That(result.Outcome).IsEqualTo(Outcome.Success);
await That(observed).IsEqualTo(true);
}

[Fact]
public async Task IsMetBy_WhenTypeDoesNotMatch_ShouldThrowInvalidOperationException()
{
DummyNode node1 = new("", () => new DummyConstraintResult<string?>(Outcome.Success, "1", ""));
DummyNode node1 = new("", () => new DummyConstraintResult(Outcome.Success));
WhichNode<string, int> whichNode = new(node1, s => s.Length);
whichNode.AddNode(new ExpectationNode());
whichNode.AddConstraint(new DummyConstraint("c2",
Expand Down Expand Up @@ -531,7 +598,7 @@ Expected that subject

Actual:
foo

Expected:
bar
""");
Expand Down
Loading