diff --git a/Source/aweXpect.Core/Core/Nodes/WhichNode.cs b/Source/aweXpect.Core/Core/Nodes/WhichNode.cs index 243c8e1af..362576f92 100644 --- a/Source/aweXpect.Core/Core/Nodes/WhichNode.cs +++ b/Source/aweXpect.Core/Core/Nodes/WhichNode.cs @@ -90,21 +90,24 @@ public override async Task IsMetBy( 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; + } - 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( @@ -112,6 +115,20 @@ public override async Task IsMetBy( .LogTrace(); } + private async Task 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); + } + /// public override bool Equals(object? obj) => obj is WhichNode other && Equals(other); @@ -225,6 +242,11 @@ public override bool TryGetValue([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)); } diff --git a/Tests/aweXpect.Core.Tests/Core/ExpectationBuilderTests.cs b/Tests/aweXpect.Core.Tests/Core/ExpectationBuilderTests.cs index 5af92fce9..098c9349f 100644 --- a/Tests/aweXpect.Core.Tests/Core/ExpectationBuilderTests.cs +++ b/Tests/aweXpect.Core.Tests/Core/ExpectationBuilderTests.cs @@ -1,4 +1,5 @@ -using System.Threading; +using System.Globalization; +using System.Threading; using aweXpect.Core.Constraints; using aweXpect.Core.Tests.TestHelpers; @@ -148,13 +149,52 @@ public async Task ForWhich_Async_CalledTwice_ShouldHonorConstraintsFromAllLevels sut.ForWhich(upperAccessor, " whose upper "); sut.AddConstraint((_, _) => new DummyConstraint(s => s == "FOO", "is FOO")); sut.ForWhich(doubledAccessor, " and whose doubled "); - sut.AddConstraint((_, _) => new DummyConstraint(s => s == "foofoo", "is foofoo")); + sut.AddConstraint((_, _) => new DummyConstraint(s => s == "FOOFOO", "is FOOFOO")); 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> stringify = i => + Task.FromResult(i.ToString(CultureInfo.InvariantCulture)); + Func> doubled = s => Task.FromResult(s + s); + ManualExpectationBuilder sut = new(null); + sut.AddConstraint((_, _) => new DummyConstraint(i => i == 12, "is 12")); + sut.ForWhich(stringify, " whose string "); + sut.AddConstraint((_, _) => new DummyConstraint(s => s == "12", "is \"12\"")); + sut.ForWhich(doubled, " and whose doubled "); + sut.AddConstraint((_, _) => new DummyConstraint(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 sut = new(null); + sut.AddConstraint((_, _) => new DummyConstraint(s => s == "foo", "is foo")); + sut.ForWhich(s => s[0], " whose first char "); + sut.AddConstraint((_, _) => new DummyConstraint(c => c == 'f', "is 'f'")); + sut.ForWhich(c => c, " whose code point "); + sut.AddConstraint((_, _) => new DummyConstraint(i => i == 'f', "is 102")); + sut.ForWhich(i => i % 2 == 0, " whose is-even "); + sut.AddConstraint((_, _) => new DummyConstraint(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] @@ -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 sut = new(null); + sut.AddConstraint((_, _) => new DummyConstraint(i => i == 123, "is 123")); + sut.ForWhich(i => i.ToString(CultureInfo.InvariantCulture), + " whose string "); + sut.AddConstraint((_, _) => new DummyConstraint(s => s == "123", "is \"123\"")); + sut.ForWhich(s => s.Length, " and whose length "); + sut.AddConstraint((_, _) => new DummyConstraint(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() { diff --git a/Tests/aweXpect.Core.Tests/Core/Nodes/WhichNodeTests.cs b/Tests/aweXpect.Core.Tests/Core/Nodes/WhichNodeTests.cs index 95d34f794..ea1e66e76 100644 --- a/Tests/aweXpect.Core.Tests/Core/Nodes/WhichNodeTests.cs +++ b/Tests/aweXpect.Core.Tests/Core/Nodes/WhichNodeTests.cs @@ -260,7 +260,7 @@ public async Task IsMetBy_EmptyExpectationNode_ShouldThrowInvalidOperationExcept Task Act() => whichNode.IsMetBy("foo", null!, CancellationToken.None); await That(Act).Throws() - .WithMessage("The expectation node does not support int with value 3"); + .WithMessage("The expectation node does not support int with value 1"); } [Fact] @@ -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(Outcome.Success, "abcd", "")); + WhichNode whichNode = new(node1, s => s.Length); + whichNode.AddNode(new ExpectationNode()); + int? observed = null; + whichNode.AddConstraint(new DummyConstraint(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 level1 = new(null, s => s![0]); + level1.AddNode(new ExpectationNode()); + level1.AddConstraint(new DummyConstraint(_ => true, "first")); + + WhichNode level2 = new(level1, c => c); + level2.AddNode(new ExpectationNode()); + level2.AddConstraint(new DummyConstraint(_ => true, "code")); + + WhichNode level3 = new(level2, i => i % 2 == 0); + level3.AddNode(new ExpectationNode()); + bool? observed = null; + level3.AddConstraint(new DummyConstraint(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 outerWhich = new(null, s => s!.Length); + outerWhich.AddNode(new ExpectationNode()); + outerWhich.AddConstraint(new DummyConstraint(_ => true, "length")); + + bool? observed = null; + WhichNode innerWhich = new(outerWhich, i => i % 2 == 0); + innerWhich.AddNode(new ExpectationNode()); + innerWhich.AddConstraint(new DummyConstraint(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(Outcome.Success, "1", "")); + DummyNode node1 = new("", () => new DummyConstraintResult(Outcome.Success)); WhichNode whichNode = new(node1, s => s.Length); whichNode.AddNode(new ExpectationNode()); whichNode.AddConstraint(new DummyConstraint("c2", @@ -531,7 +598,7 @@ Expected that subject Actual: foo - + Expected: bar """);