diff --git a/src/coverlet.core/Symbols/CecilSymbolHelper.cs b/src/coverlet.core/Symbols/CecilSymbolHelper.cs index 409a351cc..4f2f4720d 100644 --- a/src/coverlet.core/Symbols/CecilSymbolHelper.cs +++ b/src/coverlet.core/Symbols/CecilSymbolHelper.cs @@ -61,6 +61,22 @@ private static bool IsMoveNextInsideAsyncStateMachine(MethodDefinition methodDef return false; } + private static bool IsMoveNextInsideAsyncIterator(MethodDefinition methodDefinition) + { + if (methodDefinition.FullName.EndsWith("::MoveNext()") && IsCompilerGenerated(methodDefinition)) + { + foreach (InterfaceImplementation implementedInterface in methodDefinition.DeclaringType.Interfaces) + { + if (implementedInterface.InterfaceType.FullName.StartsWith("System.Collections.Generic.IAsyncEnumerator`1<")) + { + return true; + } + } + } + + return false; + } + private static bool IsMoveNextInsideEnumerator(MethodDefinition methodDefinition) { if (!methodDefinition.FullName.EndsWith("::MoveNext()")) @@ -396,11 +412,267 @@ private bool SkipGeneratedBranchesForExceptionHandlers(MethodDefinition methodDe return _compilerGeneratedBranchesToExclude[methodDefinition.FullName].Contains(instruction.Offset); } + private static bool SkipGeneratedBranchesForAwaitForeach(List instructions, Instruction instruction) + { + // An "await foreach" causes four additional branches to be generated + // by the compiler. We want to skip the last three, but we want to + // keep the first one. + // + // (1) At each iteration of the loop, a check that there is another + // item in the sequence. This is a branch that we want to keep, + // because it's basically "should we stay in the loop or not?", + // which is germane to code coverage testing. + // (2) A check near the end for whether the IAsyncEnumerator was ever + // obtained, so it can be disposed. + // (3) A check for whether an exception was thrown in the most recent + // loop iteration. + // (4) A check for whether the exception thrown in the most recent + // loop iteration has (at least) the type System.Exception. + // + // If we're looking at any of the last three of those four branches, + // we should be skipping it. + + int currentIndex = instructions.BinarySearch(instruction, new InstructionByOffsetComparer()); + + return SkipGeneratedBranchForAwaitForeach_CheckForAsyncEnumerator(instructions, instruction, currentIndex) || + SkipGeneratedBranchForAwaitForeach_CheckIfExceptionThrown(instructions, instruction, currentIndex) || + SkipGeneratedBranchForAwaitForeach_CheckThrownExceptionType(instructions, instruction, currentIndex); + } + + // The pattern for the "should we stay in the loop or not?", which we don't + // want to skip (so we have no method to try to find it), looks like this: + // + // IL_0111: ldloca.s 4 + // IL_0113: call instance !0 valuetype [System.Private.CoreLib]System.Runtime.CompilerServices.ValueTaskAwaiter`1::GetResult() + // IL_0118: brtrue IL_0047 + // + // In Debug mode, there are additional things that happen in between + // the "call" and branch, but it's the same idea either way: branch + // if GetResult() returned true. + + private static bool SkipGeneratedBranchForAwaitForeach_CheckForAsyncEnumerator(List instructions, Instruction instruction, int currentIndex) + { + // We're looking for the following pattern, which checks whether a + // compiler-generated field of type IAsyncEnumerator<> is null. + // + // IL_012b: ldarg.0 + // IL_012c: ldfld class [System.Private.CoreLib]System.Collections.Generic.IAsyncEnumerator`1 AwaitForeachStateMachine/'d__0'::'<>7__wrap1' + // IL_0131: brfalse.s IL_0196 + + if (instruction.OpCode != OpCodes.Brfalse && + instruction.OpCode != OpCodes.Brfalse_S) + { + return false; + } + + if (currentIndex >= 2 && + (instructions[currentIndex - 2].OpCode == OpCodes.Ldarg || + instructions[currentIndex - 2].OpCode == OpCodes.Ldarg_0) && + instructions[currentIndex - 1].OpCode == OpCodes.Ldfld && + instructions[currentIndex - 1].Operand is FieldDefinition field && + IsCompilerGenerated(field) && field.FieldType.FullName.StartsWith("System.Collections.Generic.IAsyncEnumerator")) + { + return true; + } + + return false; + } + + private static bool SkipGeneratedBranchForAwaitForeach_CheckIfExceptionThrown(List instructions, Instruction instruction, int currentIndex) + { + // Here, we want to find a pattern where we're checking whether a + // compiler-generated field of type Object is null. To narrow our + // search down and reduce the odds of false positives, we'll also + // expect a call to GetResult() to precede the loading of the field's + // value. The basic pattern looks like this: + // + // IL_018f: ldloca.s 2 + // IL_0191: call instance void [System.Private.CoreLib]System.Runtime.CompilerServices.ValueTaskAwaiter::GetResult() + // IL_0196: ldarg.0 + // IL_0197: ldfld object AwaitForeachStateMachine/'d__0'::'<>7__wrap2' + // IL_019c: stloc.s 6 + // IL_019e: ldloc.s 6 + // IL_01a0: brfalse.s IL_01b9 + // + // Variants are possible (e.g., a "dup" instruction instead of a + // "stloc.s" and "ldloc.s" pair), so we'll just look for the + // highlights. + + if (instruction.OpCode != OpCodes.Brfalse && + instruction.OpCode != OpCodes.Brfalse_S) + { + return false; + } + + // We expect the field to be loaded no more than thre instructions before + // the branch, so that's how far we're willing to search for it. + int minFieldIndex = Math.Max(0, currentIndex - 3); + + for (int i = currentIndex - 1; i >= minFieldIndex; --i) + { + if (instructions[i].OpCode == OpCodes.Ldfld && + instructions[i].Operand is FieldDefinition field && + IsCompilerGenerated(field) && field.FieldType.FullName == "System.Object") + { + // We expect the call to GetResult() to be no more than three + // instructions before the loading of the field's value. + int minCallIndex = Math.Max(0, i - 3); + + for (int j = i - 1; j >= minCallIndex; --j) + { + if (instructions[j].OpCode == OpCodes.Call && + instructions[j].Operand is MethodReference callRef && + callRef.DeclaringType.FullName.StartsWith("System.Runtime.CompilerServices") && + callRef.DeclaringType.FullName.Contains("TaskAwait") && + callRef.Name == "GetResult") + { + return true; + } + } + } + } + + return false; + } + + private static bool SkipGeneratedBranchForAwaitForeach_CheckThrownExceptionType(List instructions, Instruction instruction, int currentIndex) + { + // In this case, we're looking for a branch generated by the compiler to + // check whether a previously-thrown exception has (at least) the type + // System.Exception, the pattern for which looks like this: + // + // IL_01db: ldloc.s 7 + // IL_01dd: isinst [System.Private.CoreLib]System.Exception + // IL_01e2: stloc.s 9 + // IL_01e4: ldloc.s 9 + // IL_01e6: brtrue.s IL_01eb + // + // Once again, variants are possible here, such as a "dup" instruction in + // place of the "stloc.s" and "ldloc.s" pair, and we'll reduce the odds of + // a false positive by requiring a "ldloc.s" instruction to precede the + // "isinst" instruction. + + if (instruction.OpCode != OpCodes.Brtrue && + instruction.OpCode != OpCodes.Brtrue_S) + { + return false; + } + + int minTypeCheckIndex = Math.Max(1, currentIndex - 3); + + for (int i = currentIndex - 1; i >= minTypeCheckIndex; --i) + { + if (instructions[i].OpCode == OpCodes.Isinst && + instructions[i].Operand is TypeReference typeRef && + typeRef.FullName == "System.Exception" && + (instructions[i - 1].OpCode == OpCodes.Ldloc || + instructions[i - 1].OpCode == OpCodes.Ldloc_S)) + { + return true; + } + } + + return false; + } + + // https://docs.microsoft.com/en-us/archive/msdn-magazine/2019/november/csharp-iterating-with-async-enumerables-in-csharp-8 + private static bool SkipGeneratedBranchesForAsyncIterator(List instructions, Instruction instruction) + { + // There are two branch patterns that we want to eliminate in the + // MoveNext() method in compiler-generated async iterators. + // + // (1) A "switch" instruction near the beginning of MoveNext() that checks + // the state machine's current state and jumps to the right place. + // (2) A check that the compiler-generated field "<>w__disposeMode" is false, + // which is used to know whether the enumerator has been disposed (so it's + // necessary not to iterate any further). This is done in more than once + // place, but we always want to skip it. + + int currentIndex = instructions.BinarySearch(instruction, new InstructionByOffsetComparer()); + + return SkipGeneratedBranchesForAsyncIterator_CheckForStateSwitch(instructions, instruction, currentIndex) || + SkipGeneratedBranchesForAsyncIterator_DisposeCheck(instructions, instruction, currentIndex); + } + + private static bool SkipGeneratedBranchesForAsyncIterator_CheckForStateSwitch(List instructions, Instruction instruction, int currentIndex) + { + // The pattern we're looking for here is this one: + // + // IL_0000: ldarg.0 + // IL_0001: ldfld int32 Test.AsyncEnumerableStateMachine/'d__0'::'<>1__state' + // IL_0006: stloc.0 + // .try + // { + // IL_0007: ldloc.0 + // IL_0008: ldc.i4.s -4 + // IL_000a: sub + // IL_000b: switch (IL_0026, IL_002b, IL_002f, IL_002f, IL_002d) + // + // The "switch" instruction is the branch we want to skip. To eliminate + // false positives, we'll search back for the "ldfld" of the compiler- + // generated "<>1__state" field, making sure it precedes it within five + // instructions. To be safe, we'll also require a "ldarg.0" instruction + // before the "ldfld". + + if (instruction.OpCode != OpCodes.Switch) + { + return false; + } + + int minLoadStateFieldIndex = Math.Max(1, currentIndex - 5); + + for (int i = currentIndex - 1; i >= minLoadStateFieldIndex; --i) + { + if (instructions[i].OpCode == OpCodes.Ldfld && + instructions[i].Operand is FieldDefinition field && + IsCompilerGenerated(field) && field.FullName.EndsWith("__state") && + (instructions[i - 1].OpCode == OpCodes.Ldarg || + instructions[i - 1].OpCode == OpCodes.Ldarg_0)) + { + return true; + } + } + + return false; + } + + private static bool SkipGeneratedBranchesForAsyncIterator_DisposeCheck(List instructions, Instruction instruction, int currentIndex) + { + // Within the compiler-generated async iterator, there are at least a + // couple of places where we find this pattern, in which the async + // iterator is checking whether it's been disposed, so it'll know to + // stop iterating. + // + // IL_0024: ldarg.0 + // IL_0025: ldfld bool Test.AsyncEnumerableStateMachine/'d__0'::'<>w__disposeMode' + // IL_002a: brfalse.s IL_0031 + // + // We'll eliminate these wherever they appear. It's reasonable to just + // look for a "brfalse" or "brfalse.s" instruction, preceded immediately + // by "ldfld" of the compiler-generated "<>w__disposeMode" field. + + if (instruction.OpCode != OpCodes.Brfalse && + instruction.OpCode != OpCodes.Brfalse_S) + { + return false; + } + + if (currentIndex >= 1 && + instructions[currentIndex - 1].OpCode == OpCodes.Ldfld && + instructions[currentIndex - 1].Operand is FieldDefinition field && + IsCompilerGenerated(field) && field.FullName.EndsWith("__disposeMode")) + { + return true; + } + + return false; + } + // https://github.com/dotnet/roslyn/blob/master/docs/compilers/CSharp/Expression%20Breakpoints.md - private bool SkipExpressionBreakpointsBranches(Instruction instruction) => instruction.Previous is not null && instruction.Previous.OpCode == OpCodes.Ldc_I4 && - instruction.Previous.Operand is int operandValue && operandValue == 1 && - instruction.Next is not null && instruction.Next.OpCode == OpCodes.Nop && - instruction.Operand == instruction.Next?.Next; + private static bool SkipExpressionBreakpointsBranches(Instruction instruction) => instruction.Previous is not null && instruction.Previous.OpCode == OpCodes.Ldc_I4 && + instruction.Previous.Operand is int operandValue && operandValue == 1 && + instruction.Next is not null && instruction.Next.OpCode == OpCodes.Nop && + instruction.Operand == instruction.Next?.Next; public IReadOnlyList GetBranchPoints(MethodDefinition methodDefinition) { @@ -415,6 +687,7 @@ public IReadOnlyList GetBranchPoints(MethodDefinition methodDefinit bool isAsyncStateMachineMoveNext = IsMoveNextInsideAsyncStateMachine(methodDefinition); bool isMoveNextInsideAsyncStateMachineProlog = isAsyncStateMachineMoveNext && IsMoveNextInsideAsyncStateMachineProlog(methodDefinition); + bool isMoveNextInsideAsyncIterator = isAsyncStateMachineMoveNext && IsMoveNextInsideAsyncIterator(methodDefinition); // State machine for enumerator uses `brfalse.s`/`beq` or `switch` opcode depending on how many `yield` we have in the method body. // For more than one `yield` a `switch` is emitted so we should only skip the first branch. In case of a single `yield` we need to @@ -461,11 +734,21 @@ public IReadOnlyList GetBranchPoints(MethodDefinition methodDefinit if (isAsyncStateMachineMoveNext) { if (SkipGeneratedBranchesForExceptionHandlers(methodDefinition, instruction, instructions) || - SkipGeneratedBranchForExceptionRethrown(instructions, instruction)) + SkipGeneratedBranchForExceptionRethrown(instructions, instruction) || + SkipGeneratedBranchesForAwaitForeach(instructions, instruction)) + { + continue; + } + } + + if (isMoveNextInsideAsyncIterator) + { + if (SkipGeneratedBranchesForAsyncIterator(instructions, instruction)) { continue; } } + if (SkipBranchGeneratedExceptionFilter(instruction, methodDefinition)) { continue; diff --git a/test/coverlet.core.tests/Coverage/CoverageTests.AsyncForeach.cs b/test/coverlet.core.tests/Coverage/CoverageTests.AsyncForeach.cs new file mode 100644 index 000000000..abeee16cd --- /dev/null +++ b/test/coverlet.core.tests/Coverage/CoverageTests.AsyncForeach.cs @@ -0,0 +1,66 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +using Coverlet.Core.Samples.Tests; +using Coverlet.Tests.Xunit.Extensions; +using Xunit; + +namespace Coverlet.Core.Tests +{ + public partial class CoverageTests + { + [Fact] + public void AsyncForeach() + { + string path = Path.GetTempFileName(); + try + { + FunctionExecutor.Run(async (string[] pathSerialize) => + { + CoveragePrepareResult coveragePrepareResult = await TestInstrumentationHelper.Run(instance => + { + int res = ((ValueTask)instance.SumWithATwist(AsyncEnumerable.Range(1, 5))).GetAwaiter().GetResult(); + res += ((ValueTask)instance.Sum(AsyncEnumerable.Range(1, 3))).GetAwaiter().GetResult(); + res += ((ValueTask)instance.SumEmpty()).GetAwaiter().GetResult(); + + return Task.CompletedTask; + }, persistPrepareResultToFile: pathSerialize[0]); + return 0; + }, new string[] { path }); + + TestInstrumentationHelper.GetCoverageResult(path) + .Document("Instrumentation.AsyncForeach.cs") + .AssertLinesCovered(BuildConfiguration.Debug, + // SumWithATwist(IAsyncEnumerable) + // Apparently due to entering and exiting the async state machine, line 17 + // (the top of an "await foreach" loop) is reached three times *plus* twice + // per loop iteration. So, in this case, with five loop iterations, we end + // up with 3 + 5 * 2 = 13 hits. + (14, 1), (15, 1), (17, 13), (18, 5), (19, 5), (20, 5), (21, 5), (22, 5), + (24, 0), (25, 0), (26, 0), (27, 5), (29, 1), (30, 1), + // Sum(IAsyncEnumerable) + (34, 1), (35, 1), (37, 9), (38, 3), (39, 3), (40, 3), (42, 1), (43, 1), + // SumEmpty() + (47, 1), (48, 1), (50, 3), (51, 0), (52, 0), (53, 0), (55, 1), (56, 1) + ) + .AssertBranchesCovered(BuildConfiguration.Debug, + // SumWithATwist(IAsyncEnumerable) + (17, 2, 1), (17, 3, 5), (19, 0, 5), (19, 1, 0), + // Sum(IAsyncEnumerable) + (37, 0, 1), (37, 1, 3), + // SumEmpty() + // If we never entered the loop, that's a branch not taken, which is + // what we want to see. + (50, 0, 1), (50, 1, 0) + ) + .ExpectedTotalNumberOfBranches(BuildConfiguration.Debug, 4); + } + finally + { + File.Delete(path); + } + } + } +} diff --git a/test/coverlet.core.tests/Coverage/CoverageTests.AsyncIterator.cs b/test/coverlet.core.tests/Coverage/CoverageTests.AsyncIterator.cs new file mode 100644 index 000000000..67aa6b323 --- /dev/null +++ b/test/coverlet.core.tests/Coverage/CoverageTests.AsyncIterator.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading.Tasks; + +using Coverlet.Core.Samples.Tests; +using Coverlet.Tests.Xunit.Extensions; +using Xunit; + +namespace Coverlet.Core.Tests +{ + public partial class CoverageTests + { + [Fact] + public void AsyncIterator() + { + string path = Path.GetTempFileName(); + try + { + FunctionExecutor.Run(async (string[] pathSerialize) => + { + CoveragePrepareResult coveragePrepareResult = await TestInstrumentationHelper.Run(instance => + { + int res = ((Task)instance.Issue1104_Repro()).GetAwaiter().GetResult(); + + return Task.CompletedTask; + }, persistPrepareResultToFile: pathSerialize[0]); + return 0; + }, new string[] { path }); + + TestInstrumentationHelper.GetCoverageResult(path) + .Document("Instrumentation.AsyncIterator.cs") + .AssertLinesCovered(BuildConfiguration.Debug, + // Issue1104_Repro() + (14, 1), (15, 1), (17, 203), (18, 100), (19, 100), (20, 100), (22, 1), (23, 1), + // CreateSequenceAsync() + (26, 1), (27, 202), (28, 100), (29, 100), (30, 100), (31, 100), (32, 1) + ) + .AssertBranchesCovered(BuildConfiguration.Debug, + // Issue1104_Repro(), + (17, 0, 1), (17, 1, 100), + // CreateSequenceAsync() + (27, 0, 1), (27, 1, 100) + ) + .ExpectedTotalNumberOfBranches(BuildConfiguration.Debug, 2); + } + finally + { + File.Delete(path); + } + } + } +} diff --git a/test/coverlet.core.tests/Samples/Instrumentation.AsyncForeach.cs b/test/coverlet.core.tests/Samples/Instrumentation.AsyncForeach.cs new file mode 100644 index 000000000..961f9df31 --- /dev/null +++ b/test/coverlet.core.tests/Samples/Instrumentation.AsyncForeach.cs @@ -0,0 +1,58 @@ +// Remember to use full name because adding new using directives change line numbers + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Coverlet.Core.Samples.Tests +{ + public class AsyncForeach + { + async public ValueTask SumWithATwist(IAsyncEnumerable ints) + { + int sum = 0; + + await foreach (int i in ints) + { + if (i > 0) + { + sum += i; + } + else + { + sum = 0; + } + } + + return sum; + } + + + async public ValueTask Sum(IAsyncEnumerable ints) + { + int sum = 0; + + await foreach (int i in ints) + { + sum += i; + } + + return sum; + } + + + async public ValueTask SumEmpty() + { + int sum = 0; + + await foreach (int i in AsyncEnumerable.Empty()) + { + sum += i; + } + + return sum; + } + } +} diff --git a/test/coverlet.core.tests/Samples/Instrumentation.AsyncIterator.cs b/test/coverlet.core.tests/Samples/Instrumentation.AsyncIterator.cs new file mode 100644 index 000000000..df8e620ec --- /dev/null +++ b/test/coverlet.core.tests/Samples/Instrumentation.AsyncIterator.cs @@ -0,0 +1,34 @@ +// Remember to use full name because adding new using directives change line numbers + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace Coverlet.Core.Samples.Tests +{ + public class AsyncIterator + { + async public Task Issue1104_Repro() + { + int sum = 0; + + await foreach (int result in CreateSequenceAsync()) + { + sum += result; + } + + return sum; + } + + async private IAsyncEnumerable CreateSequenceAsync() + { + for (int i = 0; i < 100; ++i) + { + await Task.CompletedTask; + yield return i; + } + } + } +} diff --git a/test/coverlet.core.tests/Samples/Samples.cs b/test/coverlet.core.tests/Samples/Samples.cs index 068a820a8..203555343 100644 --- a/test/coverlet.core.tests/Samples/Samples.cs +++ b/test/coverlet.core.tests/Samples/Samples.cs @@ -197,6 +197,51 @@ async public ValueTask AsyncAwait() } } + public class AwaitForeachStateMachine + { + async public ValueTask AsyncAwait(IAsyncEnumerable ints) + { + await foreach (int i in ints) + { + await default(ValueTask); + } + } + } + + public class AwaitForeachStateMachine_WithBranches + { + async public ValueTask SumWithATwist(IAsyncEnumerable ints) + { + int sum = 0; + + await foreach (int i in ints) + { + if (i > 0) + { + sum += i; + } + else + { + sum = 0; + } + } + + return sum; + } + } + + public class AsyncIteratorStateMachine + { + async public IAsyncEnumerable CreateSequenceAsync() + { + for (int i = 0; i < 100; ++i) + { + await Task.CompletedTask; + yield return i; + } + } + } + [ExcludeFromCoverage] public class ClassExcludedByCoverletCodeCoverageAttr { diff --git a/test/coverlet.core.tests/Symbols/CecilSymbolHelperTests.cs b/test/coverlet.core.tests/Symbols/CecilSymbolHelperTests.cs index 395e763aa..fbf477610 100644 --- a/test/coverlet.core.tests/Symbols/CecilSymbolHelperTests.cs +++ b/test/coverlet.core.tests/Symbols/CecilSymbolHelperTests.cs @@ -310,6 +310,75 @@ public void GetBranchPoints_IgnoresBranchesIn_AsyncAwaitValueTaskStateMachine() Assert.Empty(points); } + [Fact] + public void GetBranchPoints_IgnoresMostBranchesIn_AwaitForeachStateMachine() + { + // arrange + var nestedName = typeof(AwaitForeachStateMachine).GetNestedTypes(BindingFlags.NonPublic).First().Name; + var type = _module.Types.FirstOrDefault(x => x.FullName == typeof(AwaitForeachStateMachine).FullName); + var nestedType = type.NestedTypes.FirstOrDefault(x => x.FullName.EndsWith(nestedName)); + var method = nestedType.Methods.First(x => x.FullName.EndsWith("::MoveNext()")); + + // act + var points = _cecilSymbolHelper.GetBranchPoints(method); + + // assert + // We do expect there to be a two-way branch (stay in the loop or not?) on + // the line containing "await foreach". + Assert.NotNull(points); + Assert.Equal(2, points.Count()); + Assert.Equal(points[0].Offset, points[1].Offset); + Assert.Equal(204, points[0].StartLine); + Assert.Equal(204, points[1].StartLine); + } + + [Fact] + public void GetBranchPoints_IgnoresMostBranchesIn_AwaitForeachStateMachine_WithBranchesWithinIt() + { + // arrange + var nestedName = typeof(AwaitForeachStateMachine_WithBranches).GetNestedTypes(BindingFlags.NonPublic).First().Name; + var type = _module.Types.FirstOrDefault(x => x.FullName == typeof(AwaitForeachStateMachine_WithBranches).FullName); + var nestedType = type.NestedTypes.FirstOrDefault(x => x.FullName.EndsWith(nestedName)); + var method = nestedType.Methods.First(x => x.FullName.EndsWith("::MoveNext()")); + + // act + var points = _cecilSymbolHelper.GetBranchPoints(method); + + // assert + // We do expect there to be four branch points (two places where we can branch + // two ways), one being the "stay in the loop or not?" branch on the line + // containing "await foreach" and the other being the "if" statement inside + // the loop. + Assert.NotNull(points); + Assert.Equal(4, points.Count()); + Assert.Equal(points[0].Offset, points[1].Offset); + Assert.Equal(points[2].Offset, points[3].Offset); + Assert.Equal(219, points[0].StartLine); + Assert.Equal(219, points[1].StartLine); + Assert.Equal(217, points[2].StartLine); + Assert.Equal(217, points[3].StartLine); + } + + [Fact] + public void GetBranchesPoints_IgnoresExtraBranchesIn_AsyncIteratorStateMachine() + { + // arrange + var nestedName = typeof(AsyncIteratorStateMachine).GetNestedTypes(BindingFlags.NonPublic).First().Name; + var type = _module.Types.FirstOrDefault(x => x.FullName == typeof(AsyncIteratorStateMachine).FullName); + var nestedType = type.NestedTypes.FirstOrDefault(x => x.FullName.EndsWith(nestedName)); + var method = nestedType.Methods.First(x => x.FullName.EndsWith("::MoveNext()")); + + // act + var points = _cecilSymbolHelper.GetBranchPoints(method); + + // assert + // We do expect the "for" loop to be a branch with two branch points, but that's it. + Assert.NotNull(points); + Assert.Equal(2, points.Count()); + Assert.Equal(237, points[0].StartLine); + Assert.Equal(237, points[1].StartLine); + } + [Fact] public void GetBranchPoints_ExceptionFilter() { diff --git a/test/coverlet.core.tests/coverlet.core.tests.csproj b/test/coverlet.core.tests/coverlet.core.tests.csproj index cb97da613..2a7adf9aa 100644 --- a/test/coverlet.core.tests/coverlet.core.tests.csproj +++ b/test/coverlet.core.tests/coverlet.core.tests.csproj @@ -30,9 +30,11 @@ - + - + + +