diff --git a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/BindCommandCodeGenerator.cs b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/BindCommandCodeGenerator.cs index 906800f..c2e0b42 100644 --- a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/BindCommandCodeGenerator.cs +++ b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/BindCommandCodeGenerator.cs @@ -456,7 +456,7 @@ private static void EmitGroup( ImmutableArray allClasses, in LanguageFeatures features) { - var collapsed = features.SupportsCallerArgExpr + var collapsed = features.CollapsesIndistinguishableCallSites ? group with { Invocations = CodeGeneratorHelpers.CollapseIndistinguishableCallSites( diff --git a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/BindInteractionCodeGenerator.cs b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/BindInteractionCodeGenerator.cs index 4cea935..cc36f21 100644 --- a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/BindInteractionCodeGenerator.cs +++ b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/BindInteractionCodeGenerator.cs @@ -301,7 +301,7 @@ private static void EmitGroup( ImmutableArray allClasses, in LanguageFeatures features) { - var collapsed = features.SupportsCallerArgExpr + var collapsed = features.CollapsesIndistinguishableCallSites ? group with { Invocations = CodeGeneratorHelpers.CollapseIndistinguishableCallSites( diff --git a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/BindToCodeGenerator.cs b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/BindToCodeGenerator.cs index 6b5a957..60c2ad0 100644 --- a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/BindToCodeGenerator.cs +++ b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/BindToCodeGenerator.cs @@ -52,7 +52,7 @@ internal static class BindToCodeGenerator for (var g = 0; g < groups.Count; g++) { - var group = supportsCallerArgExpr + var group = features.CollapsesIndistinguishableCallSites ? groups[g] with { Invocations = CodeGeneratorHelpers.CollapseIndistinguishableCallSites( diff --git a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/BindingEmitterHelpers.cs b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/BindingEmitterHelpers.cs index 7ecdc3c..f4b1a3e 100644 --- a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/BindingEmitterHelpers.cs +++ b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/BindingEmitterHelpers.cs @@ -61,7 +61,7 @@ internal static class BindingEmitterHelpers for (var g = 0; g < groups.Count; g++) { - var group = snapshot.SupportsCallerArgExpr + var group = snapshot.CollapsesIndistinguishableCallSites ? groups[g] with { Invocations = CodeGeneratorHelpers.CollapseIndistinguishableCallSites( diff --git a/src/ReactiveUI.Binding.SourceGenerators/Helpers/ExtractorValidation.cs b/src/ReactiveUI.Binding.SourceGenerators/Helpers/ExtractorValidation.cs index 4bd900a..01c91a3 100644 --- a/src/ReactiveUI.Binding.SourceGenerators/Helpers/ExtractorValidation.cs +++ b/src/ReactiveUI.Binding.SourceGenerators/Helpers/ExtractorValidation.cs @@ -90,14 +90,23 @@ internal static bool HasItems(ImmutableArray items) => /// The type symbol, which may be null. /// The fully qualified type name, or when no overload could name it. /// + /// /// A call made through a type parameter binds to whatever closes it, which the call site does not name. /// Writing the parameter's own name into an overload puts an identifier no consumer declared into their /// build, so the whole compilation fails over generated code they cannot edit - including every unrelated /// call site in the project. Declining the call site leaves it on the runtime stub instead. + /// + /// + /// A static type fails the same way and reaches here by a different route: a call written through the + /// stub's declaring class - ReactiveUIBindingExtensions.WhenChanged(vm, x => x.Name) - puts that + /// class where the observed object goes, and no member can declare a parameter of it or close a generic + /// over it. Such a call resolves against that class's own members, so neither a generated overload nor an + /// interceptor matching the call written on an instance is a candidate for it either way. + /// /// [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static string? GetDeclarableTypeDisplayName(ITypeSymbol? type) => - type is INamedTypeSymbol named ? GetTypeDisplayName(named) : null; + type is INamedTypeSymbol { IsStatic: false } named ? GetTypeDisplayName(named) : null; /// /// Searches method parameters for a selector or conversion function parameter diff --git a/src/ReactiveUI.Binding.SourceGenerators/Models/LanguageFeatures.cs b/src/ReactiveUI.Binding.SourceGenerators/Models/LanguageFeatures.cs index 952a0e4..f75a818 100644 --- a/src/ReactiveUI.Binding.SourceGenerators/Models/LanguageFeatures.cs +++ b/src/ReactiveUI.Binding.SourceGenerators/Models/LanguageFeatures.cs @@ -78,4 +78,16 @@ internal readonly record struct LanguageFeatures( bool UsesReactiveRuntime = false, EquatableArray RuntimeNamespaceMembers = default, EquatableArray PrimitivesNamespaceMembers = default, - bool SupportsInterceptors = false); + bool SupportsInterceptors = false) +{ + /// Gets a value indicating whether call sites a dispatch cannot tell apart collapse to one. + /// + /// Binding the same pair of properties from more than one place is ordinary, and expression-text dispatch + /// keys on the selectors as written: the first matching branch wins, so the later ones are unreachable and + /// only drag a binding method along. An interceptor instead names the call site it replaces, so dropping one + /// leaves it carrying no attribute - on the runtime engine while the call site beside it is generated. The + /// rule lives here because each API would otherwise decide it separately, and the one that forgot would + /// silently lose a binding rather than fail to compile. + /// + internal bool CollapsesIndistinguishableCallSites => SupportsCallerArgExpr && !SupportsInterceptors; +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/DeclaringClassInvocationTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/DeclaringClassInvocationTests.cs new file mode 100644 index 0000000..0ed928a --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/DeclaringClassInvocationTests.cs @@ -0,0 +1,290 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Microsoft.CodeAnalysis.CSharp; +using ReactiveUI.Binding.SourceGenerators.Tests.Helpers; + +namespace ReactiveUI.Binding.SourceGenerators.Tests; + +/// +/// Tests the binding calls a consumer writes through the stub's declaring class - +/// ReactiveUIBindingExtensions.WhenChanged(vm, x => x.Name) rather than vm.WhenChanged(x => x.Name). +/// +/// +/// Every extractor reads the bound object from the receiver and the lambdas from the arguments after it, which +/// a call naming the class shifts by one: the receiver is the class itself and each argument sits one place +/// along. Generating from that produces a member declaring a parameter of a static type, which does not +/// compile - so the consumer's whole build fails over generated code they cannot edit, including every +/// unrelated call site in the project. Nothing could serve these call sites anyway: a call that names the +/// class resolves against that class's members, so neither a generated overload nor an interceptor matching +/// the reduced form is a candidate. They belong on the runtime stub. +/// +public class DeclaringClassInvocationTests +{ + /// The WhenChangedDispatch.g.cs name these tests generate against. + private const string WhenChangedDispatchFileName = "WhenChangedDispatch.g.cs"; + + /// The WhenAnyObservableDispatch.g.cs name these tests generate against. + private const string WhenAnyObservableDispatchFileName = "WhenAnyObservableDispatch.g.cs"; + + /// The BindOneWayDispatch.g.cs name these tests generate against. + private const string BindOneWayDispatchFileName = "BindOneWayDispatch.g.cs"; + + /// The BindToDispatch.g.cs name these tests generate against. + private const string BindToDispatchFileName = "BindToDispatch.g.cs"; + + /// The BindCommandDispatch.g.cs name these tests generate against. + private const string BindCommandDispatchFileName = "BindCommandDispatch.g.cs"; + + /// The BindInteractionDispatch.g.cs name these tests generate against. + private const string BindInteractionDispatchFileName = "BindInteractionDispatch.g.cs"; + + /// A WhenChanged call named through its declaring class emits no dispatch. + /// A task representing the asynchronous test operation. + [Test] + public async Task WhenChanged_NamedThroughItsDeclaringClass_GeneratesNoDispatch() + { + const string source = """ + using System; + using System.ComponentModel; + using ReactiveUI.Binding; + + namespace TestApp + { + public class MyViewModel : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + + public string Name { get; set; } = ""; + } + + public static class Scenario + { + public static IObservable Execute(MyViewModel vm) + { + return ReactiveUIBindingExtensions.WhenChanged(vm, x => x.Name); + } + } + } + """; + + var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10); + + await result.HasNoGeneratorDiagnostics(); + await result.CompilationSucceeds(); + await result.DoesNotHaveGeneratedSource(WhenChangedDispatchFileName); + } + + /// A WhenAnyObservable call named through its declaring class emits no dispatch. + /// A task representing the asynchronous test operation. + [Test] + public async Task WhenAnyObservable_NamedThroughItsDeclaringClass_GeneratesNoDispatch() + { + const string source = """ + using System; + using System.ComponentModel; + using ReactiveUI.Binding; + + namespace TestApp + { + public class MyViewModel : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + + public IObservable? Signal { get; set; } + } + + public static class Scenario + { + public static IObservable Execute(MyViewModel vm) + { + return ReactiveUIBindingExtensions.WhenAnyObservable(vm, x => x.Signal); + } + } + } + """; + + var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10); + + await result.HasNoGeneratorDiagnostics(); + await result.CompilationSucceeds(); + await result.DoesNotHaveGeneratedSource(WhenAnyObservableDispatchFileName); + } + + /// A BindOneWay call named through its declaring class emits no dispatch. + /// A task representing the asynchronous test operation. + [Test] + public async Task BindOneWay_NamedThroughItsDeclaringClass_GeneratesNoDispatch() + { + const string source = """ + using System; + using System.ComponentModel; + using ReactiveUI.Binding; + + namespace TestApp + { + public class MyViewModel : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + + public string Name { get; set; } = ""; + } + + public class MyView : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + + public string DisplayName { get; set; } = ""; + } + + public static class Scenario + { + public static IDisposable Execute(MyViewModel vm, MyView view) + { + return ReactiveUIBindingExtensions.BindOneWay(vm, view, x => x.Name, x => x.DisplayName); + } + } + } + """; + + var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10); + + await result.HasNoGeneratorDiagnostics(); + await result.CompilationSucceeds(); + await result.DoesNotHaveGeneratedSource(BindOneWayDispatchFileName); + } + + /// A BindTo call named through its declaring class emits no dispatch. + /// A task representing the asynchronous test operation. + [Test] + public async Task BindTo_NamedThroughItsDeclaringClass_GeneratesNoDispatch() + { + const string source = """ + using System; + using System.ComponentModel; + using ReactiveUI.Binding; + + namespace TestApp + { + public class MyView : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + + public string DisplayName { get; set; } = ""; + } + + public static class Scenario + { + public static IDisposable Execute(IObservable values, MyView view) + { + return ReactiveUIBindingExtensions.BindTo(values, view, x => x.DisplayName); + } + } + } + """; + + var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10); + + await result.HasNoGeneratorDiagnostics(); + await result.CompilationSucceeds(); + await result.DoesNotHaveGeneratedSource(BindToDispatchFileName); + } + + /// A BindCommand call named through its declaring class emits no dispatch. + /// A task representing the asynchronous test operation. + [Test] + public async Task BindCommand_NamedThroughItsDeclaringClass_GeneratesNoDispatch() + { + const string source = """ + using System; + using System.ComponentModel; + using System.Windows.Input; + using ReactiveUI.Binding; + + namespace TestApp + { + public class MyButton + { + public event EventHandler? Click; + } + + public class MyViewModel : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + + public ICommand? Save { get; set; } + } + + public class MyView : IViewFor + { + public object? ViewModel { get; set; } + + public MyButton SaveButton { get; set; } = new MyButton(); + } + + public static class Scenario + { + public static IDisposable Execute(MyViewModel vm, MyView view) + { + return ReactiveUIBindingExtensions.BindCommand(view, vm, x => x.Save, x => x.SaveButton); + } + } + } + """; + + var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10); + + await result.HasNoGeneratorDiagnostics(); + await result.CompilationSucceeds(); + await result.DoesNotHaveGeneratedSource(BindCommandDispatchFileName); + } + + /// A BindInteraction call named through its declaring class emits no dispatch. + /// A task representing the asynchronous test operation. + [Test] + public async Task BindInteraction_NamedThroughItsDeclaringClass_GeneratesNoDispatch() + { + const string source = """ + using System; + using System.ComponentModel; + using System.Threading.Tasks; + using ReactiveUI.Binding; + + namespace TestApp + { + public class MyViewModel : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + + public Interaction Confirm { get; set; } = new Interaction(); + } + + public class MyView : IViewFor + { + public object? ViewModel { get; set; } + } + + public static class Scenario + { + public static IDisposable Execute(MyViewModel vm, MyView view) + { + return ReactiveUIBindingExtensions.BindInteraction(view, vm, x => x.Confirm, Handle); + } + + private static Task Handle(IInteractionContext context) + { + context.SetOutput(true); + return Task.CompletedTask; + } + } + } + """; + + var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10); + + await result.HasNoGeneratorDiagnostics(); + await result.CompilationSucceeds(); + await result.DoesNotHaveGeneratedSource(BindInteractionDispatchFileName); + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RepeatedCallSiteDispatchTests.Interception.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RepeatedCallSiteDispatchTests.Interception.cs new file mode 100644 index 0000000..8346948 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RepeatedCallSiteDispatchTests.Interception.cs @@ -0,0 +1,79 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Runtime.CompilerServices; +using Microsoft.CodeAnalysis.CSharp; +using ReactiveUI.Binding.SourceGenerators.Helpers; +using ReactiveUI.Binding.SourceGenerators.Tests.Helpers; + +namespace ReactiveUI.Binding.SourceGenerators.Tests; + +/// +/// The same repeated call sites, generated for a build that claims each one by name. Collapsing a group to one +/// call site per distinct pair of selectors is right where dispatch keys on that text, because the later +/// branches are unreachable - but an interceptor names the call site it replaces, so a dropped one carries no +/// attribute and takes the runtime engine while its twin is generated. +/// +public partial class RepeatedCallSiteDispatchTests +{ + /// The attribute text that claims one call site. + private const string InterceptsAttribute = "InterceptsLocation("; + + /// The number of call sites each repeated scenario writes. + private const int CallSitesPerScenario = 2; + + /// Both BindOneWay call sites are claimed. + /// A task representing the asynchronous test operation. + [Test] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Task RepeatedCallSites_UnderInterception_ClaimEveryCallSite() => + AssertEveryCallSiteIsClaimed(RepeatedBindingSource, DispatchFileName); + + /// Both BindTo call sites are claimed. + /// A task representing the asynchronous test operation. + [Test] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Task RepeatedBindTo_UnderInterception_ClaimsEveryCallSite() => + AssertEveryCallSiteIsClaimed(RepeatedBindToSource, "BindToDispatch.g.cs"); + + /// Both BindCommand call sites are claimed. + /// A task representing the asynchronous test operation. + [Test] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Task RepeatedBindCommand_UnderInterception_ClaimsEveryCallSite() => + AssertEveryCallSiteIsClaimed(RepeatedBindCommandSource, "BindCommandDispatch.g.cs"); + + /// Both BindInteraction call sites are claimed. + /// A task representing the asynchronous test operation. + [Test] + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public Task RepeatedBindInteraction_UnderInterception_ClaimsEveryCallSite() => + AssertEveryCallSiteIsClaimed(RepeatedBindInteractionSource, "BindInteractionDispatch.g.cs"); + + /// Generates a scenario for an opted-in build and counts the call sites it claimed. + /// The consumer source, which writes the same call site twice. + /// The dispatch file the API generates into. + /// A task representing the asynchronous test operation. + /// + /// A compiler that describes no call site emits the overloads instead, where collapsing is what should + /// happen, so the expected count is stated for both builds rather than skipped on one. The assembly is + /// emitted because that is where the compiler checks an interceptor against the call it replaces - two + /// attributes that both claim the same call site pass every other check. + /// + private static async Task AssertEveryCallSiteIsClaimed(string source, string dispatchFileName) + { + var parseOptions = TestHelper.InterceptingParseOptionsFor(LanguageVersion.CSharp10); + var compilation = TestHelper.CreateCompilation(source, parseOptions, false, "TestAssembly", []); + var result = TestHelper.RunGenerator(compilation, parseOptions, ProbeRootNamespace, true); + + await result.CompilationSucceeds(); + + var expected = InterceptableLocationReader.IsSupported ? CallSitesPerScenario : 0; + + await result.GeneratedSourceContainsCount(dispatchFileName, InterceptsAttribute, expected); + + var (_, context) = TestHelper.EmitAndLoad(result); + context.Unload(); + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RepeatedCallSiteDispatchTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RepeatedCallSiteDispatchTests.cs index a3025b5..34704c1 100644 --- a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RepeatedCallSiteDispatchTests.cs +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/RepeatedCallSiteDispatchTests.cs @@ -12,7 +12,7 @@ namespace ReactiveUI.Binding.SourceGenerators.Tests; /// mechanisms can tell apart differs: expression text is shared by such call sites, while file and line are /// not. These scenarios pin that each mechanism generates exactly the bodies it can actually reach. /// -public class RepeatedCallSiteDispatchTests +public partial class RepeatedCallSiteDispatchTests { /// The dispatch file BindOneWay call sites are generated into. private const string DispatchFileName = "BindOneWayDispatch.g.cs"; @@ -70,42 +70,8 @@ public IDisposable Second() } """; - /// - /// Expression-text dispatch cannot tell the two call sites apart, so a second body would be unreachable - /// and is not emitted. - /// - /// A task representing the asynchronous test operation. - [Test] - public async Task RepeatedCallSites_UnderExpressionDispatch_GenerateOneBindingMethod() - { - var result = TestHelper.RunGenerator( - RepeatedBindingSource, - LanguageVersion.CSharp10, - ProbeRootNamespace); - - await result.CompilationSucceeds(); - await result.GeneratedSourceContainsCount(DispatchFileName, BindingMethodDeclaration, 1); - } - - /// File-and-line dispatch reaches each call site separately, so both keep a body of their own. - /// A task representing the asynchronous test operation. - [Test] - public async Task RepeatedCallSites_UnderFileAndLineDispatch_GenerateABindingMethodEach() - { - var result = TestHelper.RunGenerator( - RepeatedBindingSource, - LanguageVersion.CSharp7_3, - ProbeRootNamespace); - - await result.GeneratedSourceContainsCount(DispatchFileName, BindingMethodDeclaration, BodyPerCallSite); - } - - /// Two BindTo call sites spelled identically share one generated binding method. - /// A task representing the asynchronous test operation. - [Test] - public async Task RepeatedBindTo_UnderExpressionDispatch_GeneratesOneBindingMethod() - { - const string source = """ + /// Two BindTo call sites applying the same stream to the same property, spelled identically. + private const string RepeatedBindToSource = """ using System; using System.ComponentModel; using ReactiveUI.Binding; @@ -134,21 +100,8 @@ public static IDisposable Second(IObservable source, ProbeView view) } """; - var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10, ProbeRootNamespace); - - await result.CompilationSucceeds(); - await result.GeneratedSourceContainsCount( - "BindToDispatch.g.cs", - "private static global::System.IDisposable __BindTo_", - 1); - } - - /// Two BindCommand call sites spelled identically share one generated binding method. - /// A task representing the asynchronous test operation. - [Test] - public async Task RepeatedBindCommand_UnderExpressionDispatch_GeneratesOneBindingMethod() - { - const string source = """ + /// Two BindCommand call sites binding the same command to the same control, spelled identically. + private const string RepeatedBindCommandSource = """ using System; using System.ComponentModel; using System.Windows.Input; @@ -198,20 +151,8 @@ public static void Second(ProbeView view, ProbeViewModel vm) } """; - var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10, ProbeRootNamespace); - - await result.GeneratedSourceContainsCount( - "BindCommandDispatch.g.cs", - "__BindCommand_", - NamedOncePerBranchAndDeclaration); - } - - /// Two BindInteraction call sites spelled identically share one generated binding method. - /// A task representing the asynchronous test operation. - [Test] - public async Task RepeatedBindInteraction_UnderExpressionDispatch_GeneratesOneBindingMethod() - { - const string source = """ + /// Two BindInteraction call sites binding the same interaction, spelled identically. + private const string RepeatedBindInteractionSource = """ using System; using System.ComponentModel; using System.Threading.Tasks; @@ -252,7 +193,69 @@ public static IDisposable Second(ProbeView view, ProbeViewModel vm) } """; - var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10, ProbeRootNamespace); + /// + /// Expression-text dispatch cannot tell the two call sites apart, so a second body would be unreachable + /// and is not emitted. + /// + /// A task representing the asynchronous test operation. + [Test] + public async Task RepeatedCallSites_UnderExpressionDispatch_GenerateOneBindingMethod() + { + var result = TestHelper.RunGenerator( + RepeatedBindingSource, + LanguageVersion.CSharp10, + ProbeRootNamespace); + + await result.CompilationSucceeds(); + await result.GeneratedSourceContainsCount(DispatchFileName, BindingMethodDeclaration, 1); + } + + /// File-and-line dispatch reaches each call site separately, so both keep a body of their own. + /// A task representing the asynchronous test operation. + [Test] + public async Task RepeatedCallSites_UnderFileAndLineDispatch_GenerateABindingMethodEach() + { + var result = TestHelper.RunGenerator( + RepeatedBindingSource, + LanguageVersion.CSharp7_3, + ProbeRootNamespace); + + await result.GeneratedSourceContainsCount(DispatchFileName, BindingMethodDeclaration, BodyPerCallSite); + } + + /// Two BindTo call sites spelled identically share one generated binding method. + /// A task representing the asynchronous test operation. + [Test] + public async Task RepeatedBindTo_UnderExpressionDispatch_GeneratesOneBindingMethod() + { + var result = TestHelper.RunGenerator(RepeatedBindToSource, LanguageVersion.CSharp10, ProbeRootNamespace); + + await result.CompilationSucceeds(); + await result.GeneratedSourceContainsCount( + "BindToDispatch.g.cs", + "private static global::System.IDisposable __BindTo_", + 1); + } + + /// Two BindCommand call sites spelled identically share one generated binding method. + /// A task representing the asynchronous test operation. + [Test] + public async Task RepeatedBindCommand_UnderExpressionDispatch_GeneratesOneBindingMethod() + { + var result = TestHelper.RunGenerator(RepeatedBindCommandSource, LanguageVersion.CSharp10, ProbeRootNamespace); + + await result.GeneratedSourceContainsCount( + "BindCommandDispatch.g.cs", + "__BindCommand_", + NamedOncePerBranchAndDeclaration); + } + + /// Two BindInteraction call sites spelled identically share one generated binding method. + /// A task representing the asynchronous test operation. + [Test] + public async Task RepeatedBindInteraction_UnderExpressionDispatch_GeneratesOneBindingMethod() + { + var result = TestHelper.RunGenerator(RepeatedBindInteractionSource, LanguageVersion.CSharp10, ProbeRootNamespace); await result.GeneratedSourceContainsCount( "BindInteractionDispatch.g.cs",