From 0bad823fe06bf28350e8461043706dbdab51c511 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:30:01 +1000 Subject: [PATCH 1/2] feat(binding): execute a command with each value a stream produces - InvokeCommand joins the generated APIs. The overload naming a command property observes that path with the mechanism the generator picked for the declaring type, and offers every value the stream produces to whichever command the path currently holds - a chain is followed, so replacing a parent retargets the execution. - The overload taking the command itself has nothing to resolve and nothing to observe, so the runtime library serves it outright and no dispatch is emitted. - CanExecute is asked per emission rather than tracked from CanExecuteChanged: the value being offered is the command parameter, so only the command can answer for that value. A refused value is dropped, a null command drops the values offered while it stands, and replacing the command executes nothing by itself. - Both paths hand the resolved command to CommandInvoker, so a generated call site emits one call and the runtime engine's path cannot drift from it. - A call site the generator cannot claim reads the property through the runtime expression engine, which is what the stub's trimming annotation says. - RXUIBIND002 reads the type argument naming the observed object, which for this API is the second: the first is the value type of a stream the caller already built. RXUIBIND011 covers the API as well, so a call answered by ReactiveUI's own mixin is reported. - The overloads naming ReactiveCommandBase are not offered: naming that type would put a ReactiveUI reference in this package. A ReactiveCommand is reached through the ICommand overloads, with the parameter arriving as object. --- CLAUDE.md | 5 +- README.md | 17 +- .../Analyzers/AnalyzerHelpers.cs | 36 +- .../Analyzers/MixinShadowAnalyzer.cs | 1 + .../Analyzers/TypeAnalyzer.cs | 12 +- .../CommandBinding/CommandInvoker.cs | 156 ++++++++ .../Fallback/RuntimeCommandFallback.cs | 53 +++ ...activeUIBindingExtensions.InvokeCommand.cs | 77 ++++ .../BindingGenerator.cs | 2 + .../CodeGeneration/GeneratedTypeNames.cs | 9 + .../InvokeCommandCodeGenerator.cs | 343 ++++++++++++++++++ .../Constants.cs | 3 + .../Helpers/InvokeCommandExtractor.cs | 79 ++++ .../InvokeCommandInvocationGenerator.cs | 42 +++ .../Models/InvokeCommandInvocationInfo.cs | 28 ++ .../RoslynHelpers.cs | 12 + .../MixinShadowAnalyzerTests.cs | 11 + .../TypeAnalyzerTests.InvokeCommand.cs | 100 +++++ .../TypeAnalyzerTests.cs | 2 +- .../AotCommand.cs | 37 ++ .../AotViewModel.cs | 18 + .../Program.cs | 20 + .../Scenarios/InvokeCommandScenarios.cs | 32 ++ .../Binding/InvokeCommandTests.cs | 176 +++++++++ .../Helpers/TestHelper.cs | 1 + ...#GeneratedBinderRegistration.g.verified.cs | 25 ++ ...#GeneratedBindingsAttributes.g.verified.cs | 12 + ...operty#InvokeCommandDispatch.g.verified.cs | 66 ++++ ...#GeneratedBinderRegistration.g.verified.cs | 24 ++ ...#GeneratedBindingsAttributes.g.verified.cs | 10 + ...ty_CFP#InvokeCommandDispatch.g.verified.cs | 62 ++++ ...#GeneratedBinderRegistration.g.verified.cs | 25 ++ ...#GeneratedBindingsAttributes.g.verified.cs | 12 + ...ndPath#InvokeCommandDispatch.g.verified.cs | 72 ++++ .../InterceptedCallSiteTests.cs | 2 + .../InvokeCommandGeneratorTests.cs | 259 +++++++++++++ ...veUI.Binding.SourceGenerators.Tests.csproj | 1 + .../CommandBinding/CommandInvokerTests.cs | 259 +++++++++++++ .../Mixins/InvokeCommandTests.cs | 102 ++++++ .../TestModels/RecordingCommand.cs | 44 +++ .../CommandProperty/MyViewModel.cs | 34 ++ .../InvokeCommand/CommandProperty/Scenario.cs | 21 ++ .../DeepCommandPath/ChildViewModel.cs | 34 ++ .../DeepCommandPath/MyViewModel.cs | 33 ++ .../InvokeCommand/DeepCommandPath/Scenario.cs | 21 ++ 45 files changed, 2382 insertions(+), 8 deletions(-) create mode 100644 src/ReactiveUI.Binding.Shared/CommandBinding/CommandInvoker.cs create mode 100644 src/ReactiveUI.Binding.Shared/Fallback/RuntimeCommandFallback.cs create mode 100644 src/ReactiveUI.Binding.Shared/Mixins/ReactiveUIBindingExtensions.InvokeCommand.cs create mode 100644 src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/InvokeCommandCodeGenerator.cs create mode 100644 src/ReactiveUI.Binding.SourceGenerators/Helpers/InvokeCommandExtractor.cs create mode 100644 src/ReactiveUI.Binding.SourceGenerators/Invocations/InvokeCommandInvocationGenerator.cs create mode 100644 src/ReactiveUI.Binding.SourceGenerators/Models/InvokeCommandInvocationInfo.cs create mode 100644 src/tests/ReactiveUI.Binding.Analyzer.Tests/TypeAnalyzerTests.InvokeCommand.cs create mode 100644 src/tests/ReactiveUI.Binding.AotValidation/AotCommand.cs create mode 100644 src/tests/ReactiveUI.Binding.GeneratedCode.TestModels/Scenarios/InvokeCommandScenarios.cs create mode 100644 src/tests/ReactiveUI.Binding.GeneratedCode.Tests/Binding/InvokeCommandTests.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty#GeneratedBinderRegistration.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty#GeneratedBindingsAttributes.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty#InvokeCommandDispatch.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty_CFP#GeneratedBinderRegistration.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty_CFP#GeneratedBindingsAttributes.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty_CFP#InvokeCommandDispatch.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.DeepCommandPath#GeneratedBinderRegistration.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.DeepCommandPath#GeneratedBindingsAttributes.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.DeepCommandPath#InvokeCommandDispatch.g.verified.cs create mode 100644 src/tests/ReactiveUI.Binding.SourceGenerators.Tests/InvokeCommandGeneratorTests.cs create mode 100644 src/tests/ReactiveUI.Binding.Tests/CommandBinding/CommandInvokerTests.cs create mode 100644 src/tests/ReactiveUI.Binding.Tests/Mixins/InvokeCommandTests.cs create mode 100644 src/tests/ReactiveUI.Binding.Tests/TestModels/RecordingCommand.cs create mode 100644 src/tests/SharedScenarios/InvokeCommand/CommandProperty/MyViewModel.cs create mode 100644 src/tests/SharedScenarios/InvokeCommand/CommandProperty/Scenario.cs create mode 100644 src/tests/SharedScenarios/InvokeCommand/DeepCommandPath/ChildViewModel.cs create mode 100644 src/tests/SharedScenarios/InvokeCommand/DeepCommandPath/MyViewModel.cs create mode 100644 src/tests/SharedScenarios/InvokeCommand/DeepCommandPath/Scenario.cs diff --git a/CLAUDE.md b/CLAUDE.md index a6c50b99..cf58e324 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -213,7 +213,8 @@ src/ │ │ ├── WhenChangingInvocationGenerator.cs # Before-change observation │ │ ├── BindOneWayInvocationGenerator.cs # One-way binding │ │ ├── BindTwoWayInvocationGenerator.cs # Two-way binding -│ │ └── WhenAnyValueInvocationGenerator.cs # WhenAnyValue compat shim +│ │ ├── WhenAnyValueInvocationGenerator.cs # WhenAnyValue compat shim +│ │ └── InvokeCommandInvocationGenerator.cs # Stream-driven command execution │ ├── Helpers/ # Extraction and validation helpers │ │ ├── ViewRegistrationExtractor.cs # IViewFor → ViewRegistrationInfo extraction │ │ └── ... # ExtractorValidation, SymbolHelpers, etc. @@ -263,7 +264,7 @@ generic type inference dominate the `GcVerbose` trace), so a second semantic pas not affordable. It also means a type from a *referenced* assembly is observed correctly even though the declaration scan never sees it. -**Pipeline B (Invocation Detection)**: Scans method invocations (`WhenChanged`, `WhenChanging`, `BindOneWay`, `BindTwoWay`, `WhenAnyValue`) → extracts lambda property paths → generates optimized per-call-site observation/binding code. Uses **CallerFilePath + CallerLineNumber dispatch**: API stubs capture caller info, generated dispatch table routes to compile-time generated methods. +**Pipeline B (Invocation Detection)**: Scans method invocations (`WhenChanged`, `WhenChanging`, `BindOneWay`, `BindTwoWay`, `WhenAnyValue`, `InvokeCommand`) → extracts lambda property paths → generates optimized per-call-site observation/binding code. Uses **CallerFilePath + CallerLineNumber dispatch**: API stubs capture caller info, generated dispatch table routes to compile-time generated methods. **Pipeline C (View Dispatch)**: Scans classes implementing `IViewFor` → extracts `ViewRegistrationInfo` POCOs (VM FQN, View FQN, constructor availability, `[ViewContract]` contract, `[SingleInstanceView]` flag) → generates `ViewDispatch.g.cs` with a type-switch dispatch function. Supports contract-based multi-view resolution (contract checks emitted before default), singleton caching via `Interlocked.CompareExchange`, and 3-tier resolution (service locator → direct construction → null). Views can be excluded with `[ExcludeFromViewRegistration]`. diff --git a/README.md b/README.md index 9cf4f035..6f2830e7 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ generation. Zero reflection, fully AOT/trimming safe, 3-7x faster than the legac ReactiveUI.Binding.SourceGenerators is an incremental source generator that analyses your `WhenChanged`, `WhenChanging`, `WhenAnyValue`, `WhenAny`, `WhenAnyObservable`, `BindOneWay`, `BindTwoWay`, `OneWayBind`, `Bind`, `BindTo`, -`BindCommand`, and `BindInteraction` call sites at compile time and emits optimised, strongly-typed observation and +`BindCommand`, `BindInteraction`, and `InvokeCommand` call sites at compile time and emits optimised, strongly-typed observation and binding code. It eliminates: - **Runtime expression-tree compilation** -- no `Expression>` evaluation at runtime @@ -186,6 +186,7 @@ Platform-specific packages provide DependencyProperty observation and other plat | `BindTo` | Apply an observable stream to a target property | | `BindCommand` | Bind a command property to a UI element | | `BindInteraction` | Bind an interaction to a handler | +| `InvokeCommand` | Execute a command with each value an observable produces | All APIs support single properties, deep property chains (e.g. `x => x.Address.City`), and multi-property observation ( up to 12 properties for `WhenAnyValue`/`WhenChanged`). @@ -271,6 +272,20 @@ IDisposable binding = view.OneWayBind(vm, x => x.Name, x => x.NameLabel); IDisposable binding = view.Bind(vm, x => x.Name, x => x.NameTextBox); ``` +### Invoking a Command + +```csharp +// Execute the command a view model property holds with each value the stream produces. The value is the +// command parameter, and a value the command refuses through CanExecute is dropped. +IDisposable invocation = searchText.InvokeCommand(vm, x => x.Search); + +// Execute a command the caller already has, which needs no property to observe. +IDisposable invocation = searchText.InvokeCommand(vm.Search); +``` + +A `ReactiveCommand` is reached through these overloads like any other `ICommand`; the parameter arrives as +`object` rather than the command's declared input type. + ### Scheduler Overloads Scheduler overloads require the `ReactiveUI.Binding.Reactive` package: diff --git a/src/ReactiveUI.Binding.Analyzer/Analyzers/AnalyzerHelpers.cs b/src/ReactiveUI.Binding.Analyzer/Analyzers/AnalyzerHelpers.cs index 13f0d23c..7269d79d 100644 --- a/src/ReactiveUI.Binding.Analyzer/Analyzers/AnalyzerHelpers.cs +++ b/src/ReactiveUI.Binding.Analyzer/Analyzers/AnalyzerHelpers.cs @@ -100,7 +100,22 @@ internal static bool HasBeforeChangeSupport( /// The method symbol to extract from. /// The first type argument as , or null. [MethodImpl(MethodImplOptions.AggressiveInlining)] - internal static INamedTypeSymbol? ExtractFirstTypeArgument(IMethodSymbol methodSymbol) => methodSymbol.TypeArguments.IsEmpty ? null : methodSymbol.TypeArguments[0] as INamedTypeSymbol; + internal static INamedTypeSymbol? ExtractFirstTypeArgument(IMethodSymbol methodSymbol) => ExtractTypeArgument(methodSymbol, 0); + + /// + /// Extracts one of a method's type arguments as an . Returns null when the + /// method has fewer arguments than that, or when the one asked for is not a named type. + /// + /// The method symbol to extract from. + /// Which type argument to read. + /// The type argument as , or null. + /// + /// Which argument names the observed object differs by API: most name it first, while the ones taking a + /// stream the caller already built name that stream's value type there instead. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static INamedTypeSymbol? ExtractTypeArgument(IMethodSymbol methodSymbol, int index) => + methodSymbol.TypeArguments.Length <= index ? null : methodSymbol.TypeArguments[index] as INamedTypeSymbol; /// /// Determines whether a method's first type argument lacks any observable notification mechanism. @@ -110,12 +125,29 @@ internal static bool HasBeforeChangeSupport( /// The current compilation. /// The resolved source type, if the check matched. /// true if the type has no observable mechanism. + [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static bool LacksObservableMechanism( IMethodSymbol methodSymbol, Compilation compilation, + out INamedTypeSymbol? sourceType) => + LacksObservableMechanism(methodSymbol, compilation, 0, out sourceType); + + /// + /// Determines whether the type argument naming this API's observed object lacks any observable notification + /// mechanism. Returns false when the method has no such argument (a non-generic dispatch overload). + /// + /// The method symbol. + /// The current compilation. + /// Which type argument names the observed object. + /// The resolved source type, if the check matched. + /// true if the type has no observable mechanism. + internal static bool LacksObservableMechanism( + IMethodSymbol methodSymbol, + Compilation compilation, + int typeArgumentIndex, out INamedTypeSymbol? sourceType) { - sourceType = ExtractFirstTypeArgument(methodSymbol); + sourceType = ExtractTypeArgument(methodSymbol, typeArgumentIndex); return sourceType is not null && !TypeAnalyzer.HasObservableMechanism(sourceType, compilation); } diff --git a/src/ReactiveUI.Binding.Analyzer/Analyzers/MixinShadowAnalyzer.cs b/src/ReactiveUI.Binding.Analyzer/Analyzers/MixinShadowAnalyzer.cs index 64b1a913..194cd621 100644 --- a/src/ReactiveUI.Binding.Analyzer/Analyzers/MixinShadowAnalyzer.cs +++ b/src/ReactiveUI.Binding.Analyzer/Analyzers/MixinShadowAnalyzer.cs @@ -52,6 +52,7 @@ public class MixinShadowAnalyzer : DiagnosticAnalyzer Constants.BindToMethodName, Constants.BindCommandMethodName, Constants.BindInteractionMethodName, + Constants.InvokeCommandMethodName, }.ToImmutableHashSet(StringComparer.Ordinal); /// The diagnostics this analyzer reports. diff --git a/src/ReactiveUI.Binding.Analyzer/Analyzers/TypeAnalyzer.cs b/src/ReactiveUI.Binding.Analyzer/Analyzers/TypeAnalyzer.cs index 61ec575d..4e76ed60 100644 --- a/src/ReactiveUI.Binding.Analyzer/Analyzers/TypeAnalyzer.cs +++ b/src/ReactiveUI.Binding.Analyzer/Analyzers/TypeAnalyzer.cs @@ -46,7 +46,7 @@ internal static void AnalyzeInvocation(in OperationAnalysisContext context) return; } - // The check reads the first type argument, which every other API names the observed object with. + // The check reads the first type argument, which most APIs name the observed object with. // BindTo names the value type of a stream the caller already built, and nothing about that type is // ever observed, so asking whether it notifies has no answer worth reporting. if (methodSymbol.Name == Constants.BindToMethodName) @@ -54,8 +54,16 @@ internal static void AnalyzeInvocation(in OperationAnalysisContext context) return; } + // InvokeCommand names that stream's value type first as well; the object it observes is the one holding + // the command, which it names second. + var observedTypeArgument = methodSymbol.Name == Constants.InvokeCommandMethodName ? 1 : 0; + // Check if the source type lacks any observable mechanism - if (!AnalyzerHelpers.LacksObservableMechanism(methodSymbol, context.Compilation, out var sourceType)) + if (!AnalyzerHelpers.LacksObservableMechanism( + methodSymbol, + context.Compilation, + observedTypeArgument, + out var sourceType)) { return; } diff --git a/src/ReactiveUI.Binding.Shared/CommandBinding/CommandInvoker.cs b/src/ReactiveUI.Binding.Shared/CommandBinding/CommandInvoker.cs new file mode 100644 index 00000000..2654e053 --- /dev/null +++ b/src/ReactiveUI.Binding.Shared/CommandBinding/CommandInvoker.cs @@ -0,0 +1,156 @@ +// 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.ComponentModel; +using System.Runtime.ExceptionServices; +using System.Windows.Input; + +#if REACTIVE_SHIM +namespace ReactiveUI.Binding.Reactive.CommandBinding; +#else +namespace ReactiveUI.Binding.CommandBinding; +#endif + +/// Executes a command with each value a sequence produces. +/// +/// +/// What an InvokeCommand does once the command is in hand, whether the generator resolved the command +/// property at compile time or the runtime engine read it from the expression. Both hand the command here rather +/// than writing the gating themselves, so a generated call site emits one call and the two paths cannot drift. +/// +/// +/// is asked at each emission rather than tracked from +/// : the value being offered is the command parameter, so only the +/// command can answer for that value, and an answer tracked from the event would have to be recomputed per value +/// anyway. A value the command refuses is dropped rather than held. +/// +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public static class CommandInvoker +{ + /// Executes one command with each value the sequence produces. + /// The type of the value offered as the command parameter. + /// The sequence driving the executions. + /// The command to execute. + /// A disposable that, when disposed, stops executing the command. + /// or is null. + public static IDisposable Invoke(IObservable source, ICommand command) + { + ArgumentExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(command); + + return source.Subscribe(new FixedCommandObserver(command)); + } + + /// Executes whichever command the observed sequence of commands last produced. + /// The type of the value offered as the command parameter. + /// The sequence driving the executions. + /// The command to execute, as the observed property produces it. + /// A disposable that, when disposed, stops executing and stops observing the property. + /// or is null. + /// + /// + /// The commands are subscribed first, so the command the property already holds is latched before any value + /// can arrive. Subscribing in the other order drops a value produced immediately. + /// + /// + /// A null command - a property not yet assigned, or a path through an absent parent - drops the values + /// offered while it stands, and a later command picks up from the next value. Replacing the command executes + /// nothing by itself: values are what drive an execution, which is why the two sequences are not combined. + /// + /// + public static IDisposable Invoke(IObservable source, IObservable commands) + { + ArgumentExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(commands); + + var latch = new CommandLatch(); + var commandSubscription = commands.Subscribe(latch); + + return new MultipleDisposable(commandSubscription, source.Subscribe(new LatchedCommandObserver(latch))); + } + + /// Offers each value to a command fixed for the lifetime of the subscription. + /// The type of the value offered as the command parameter. + /// The command to execute. + private sealed class FixedCommandObserver(ICommand command) : IObserver + { + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnCompleted() + { + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnError(Exception error) => ExceptionDispatchInfo.Capture(error).Throw(); + + /// + public void OnNext(T value) + { + if (!command.CanExecute(value)) + { + return; + } + + command.Execute(value); + } + } + + /// Holds the command an observed property last produced. + /// + /// The command is written on whichever thread raised the property notification and read on whichever thread + /// a value arrives on, which are not the same thread in the general case. + /// + private sealed class CommandLatch : IObserver + { + /// The command the property last produced. + private ICommand? _command; + + /// Gets the command the property last produced. + internal ICommand? Command => Volatile.Read(ref _command); + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnCompleted() + { + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnError(Exception error) => ExceptionDispatchInfo.Capture(error).Throw(); + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnNext(ICommand? value) => Volatile.Write(ref _command, value); + } + + /// Offers each value to whichever command the latch currently holds. + /// The type of the value offered as the command parameter. + /// The latch holding the command. + private sealed class LatchedCommandObserver(CommandLatch latch) : IObserver + { + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnCompleted() + { + } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void OnError(Exception error) => ExceptionDispatchInfo.Capture(error).Throw(); + + /// + public void OnNext(T value) + { + var command = latch.Command; + if (command is null || !command.CanExecute(value)) + { + return; + } + + command.Execute(value); + } + } +} diff --git a/src/ReactiveUI.Binding.Shared/Fallback/RuntimeCommandFallback.cs b/src/ReactiveUI.Binding.Shared/Fallback/RuntimeCommandFallback.cs new file mode 100644 index 00000000..6bc605dc --- /dev/null +++ b/src/ReactiveUI.Binding.Shared/Fallback/RuntimeCommandFallback.cs @@ -0,0 +1,53 @@ +// 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.ComponentModel; +using System.Windows.Input; + +#if REACTIVE_SHIM +namespace ReactiveUI.Binding.Reactive.Fallback; +#else +namespace ReactiveUI.Binding.Fallback; +#endif + +/// Resolves the command an InvokeCommand executes through the runtime expression engine. +/// +/// Reached only where the generator could not serve the call site - a receiver it cannot name, a selector that is +/// not an inline lambda, or a build whose compiler predates the dispatch this package emits. The executions +/// themselves are the generated path's, because both hand the resolved command to +/// ; only how the command is found differs. +/// +[EditorBrowsable(EditorBrowsableState.Never)] +public static class RuntimeCommandFallback +{ + /// Executes the command an observed property holds with each value the sequence produces. + /// The type of the value offered as the command parameter. + /// The type declaring the observed command property. + /// The sequence driving the executions. + /// The object declaring the command property. + /// The property holding the command to execute. + /// A disposable that, when disposed, stops executing the command. + /// or is null. + /// + /// A null target holds no property to observe, so the values are dropped rather than faulting the sequence - + /// the same outcome as a target whose command property is null, which is the ordinary case before a view + /// model is assigned. + /// + [RequiresUnreferencedCode("Runtime command fallback resolves the property chain by reflection.")] + public static IDisposable InvokeCommand( + IObservable source, + TTarget? target, + Expression> commandProperty) + where TTarget : class + { + ArgumentExceptionHelper.ThrowIfNull(source); + ArgumentExceptionHelper.ThrowIfNull(commandProperty); + + return target is null + ? EmptyDisposable.Instance + : CommandBinding.CommandInvoker.Invoke( + source, + RuntimeObservationFallback.WhenAnyValue(target, commandProperty)); + } +} diff --git a/src/ReactiveUI.Binding.Shared/Mixins/ReactiveUIBindingExtensions.InvokeCommand.cs b/src/ReactiveUI.Binding.Shared/Mixins/ReactiveUIBindingExtensions.InvokeCommand.cs new file mode 100644 index 00000000..cd74a7ac --- /dev/null +++ b/src/ReactiveUI.Binding.Shared/Mixins/ReactiveUIBindingExtensions.InvokeCommand.cs @@ -0,0 +1,77 @@ +// 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.Windows.Input; + +#if REACTIVE_SHIM +namespace ReactiveUI.Binding.Reactive; +#else +namespace ReactiveUI.Binding; +#endif + +/// Extension methods that execute a command with each value an observable produces (InvokeCommand). +/// +/// The command is offered every value the sequence produces, as the command parameter, and a value the command +/// refuses is dropped. A ReactiveCommand is reached through these overloads like any other +/// : the parameter is passed as rather than the command's input type. +/// +public static partial class ReactiveUIBindingExtensions +{ + /// Executes a command with each value the sequence produces. + /// The type of the value offered as the command parameter. + /// The sequence driving the executions. + /// The command to execute. + /// A disposable that, when disposed, stops executing the command. + /// or is null. + /// + /// The command is the caller's own object rather than a property to observe, so there is nothing here for the + /// generator to resolve and no dispatch is emitted for this overload. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IDisposable InvokeCommand(this IObservable source, ICommand command) => + CommandBinding.CommandInvoker.Invoke(source, command); + +#if NET8_0_OR_GREATER + /// Executes the command a property holds with each value the sequence produces. + /// The type of the value offered as the command parameter. + /// The type declaring the command property. + /// The sequence driving the executions. + /// The object declaring the command property. + /// An expression that selects the command property to execute. + /// The caller argument expression for . Auto-populated by the compiler. + /// The source file path of the caller. Auto-populated by the compiler. + /// The source line number of the caller. Auto-populated by the compiler. + /// A disposable that, when disposed, stops executing the command and stops observing the property. + /// or is null. + [RequiresUnreferencedCode("Runtime command fallback resolves the property chain by reflection.")] + public static IDisposable InvokeCommand( + this IObservable source, + TTarget? target, + Expression> commandProperty, + [CallerArgumentExpression("commandProperty")] string commandPropertyExpression = "", + [CallerFilePath] string callerFilePath = "", + [CallerLineNumber] int callerLineNumber = 0) + where TTarget : class +#else + /// Executes the command a property holds with each value the sequence produces. + /// The type of the value offered as the command parameter. + /// The type declaring the command property. + /// The sequence driving the executions. + /// The object declaring the command property. + /// An expression that selects the command property to execute. + /// The source file path of the caller. Auto-populated by the compiler. + /// The source line number of the caller. Auto-populated by the compiler. + /// A disposable that, when disposed, stops executing the command and stops observing the property. + /// or is null. + [RequiresUnreferencedCode("Runtime command fallback resolves the property chain by reflection.")] + public static IDisposable InvokeCommand( + this IObservable source, + TTarget? target, + Expression> commandProperty, + [CallerFilePath] string callerFilePath = "", + [CallerLineNumber] int callerLineNumber = 0) + where TTarget : class +#endif + => RuntimeCommandFallback.InvokeCommand(source, target, commandProperty); +} diff --git a/src/ReactiveUI.Binding.SourceGenerators/BindingGenerator.cs b/src/ReactiveUI.Binding.SourceGenerators/BindingGenerator.cs index 461a6568..0c9c59dc 100644 --- a/src/ReactiveUI.Binding.SourceGenerators/BindingGenerator.cs +++ b/src/ReactiveUI.Binding.SourceGenerators/BindingGenerator.cs @@ -88,6 +88,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) var bindCommand = Detect(in context, RoslynHelpers.IsBindCommandInvocation, CommandExtractor.ExtractBindCommandInvocation); var bindInteraction = Detect(in context, RoslynHelpers.IsBindInteractionInvocation, InteractionExtractor.ExtractBindInteractionInvocation); var bindTo = Detect(in context, RoslynHelpers.IsBindToInvocation, BindToExtractor.ExtractBindToInvocation); + var invokeCommand = Detect(in context, RoslynHelpers.IsInvokeCommandInvocation, InvokeCommandExtractor.ExtractInvokeCommandInvocation); // Each invocation generator receives the language-feature snapshot to control dispatch/output WhenChangedInvocationGenerator.Register(context, whenChanged, allClasses, languageFeatures); @@ -102,6 +103,7 @@ public void Initialize(IncrementalGeneratorInitializationContext context) BindInteractionInvocationGenerator.Register(context, bindInteraction, allClasses, languageFeatures); BindCommandInvocationGenerator.Register(context, bindCommand, allClasses, languageFeatures); BindToInvocationGenerator.Register(context, bindTo, languageFeatures); + InvokeCommandInvocationGenerator.Register(context, invokeCommand, allClasses, languageFeatures); } /// Reads the C# language version the consumer is compiling with. diff --git a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/GeneratedTypeNames.cs b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/GeneratedTypeNames.cs index 898b27e4..55600ab4 100644 --- a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/GeneratedTypeNames.cs +++ b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/GeneratedTypeNames.cs @@ -67,6 +67,15 @@ internal static class GeneratedTypeNames /// The fully qualified name of ReactiveUI.Binding.IBindingTypeConverter. internal const string IBindingTypeConverter = "global::ReactiveUI.Binding.IBindingTypeConverter"; + /// The fully qualified name of System.Windows.Input.ICommand. + internal const string ICommand = "global::System.Windows.Input.ICommand"; + + /// The subscription that holds nothing, handed back where there is nothing to disconnect. + internal const string EmptyDisposable = "global::ReactiveUI.Primitives.Disposables.EmptyDisposable"; + + /// The runtime gate that offers each value to a command and executes the ones it accepts. + internal const string CommandInvoker = "global::ReactiveUI.Binding.CommandBinding.CommandInvoker"; + /// The ReactiveUI.Binding.Observables namespace prefix (no trailing dot). internal const string Observables = "global::ReactiveUI.Binding.Observables"; diff --git a/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/InvokeCommandCodeGenerator.cs b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/InvokeCommandCodeGenerator.cs new file mode 100644 index 00000000..b604193b --- /dev/null +++ b/src/ReactiveUI.Binding.SourceGenerators/CodeGeneration/InvokeCommandCodeGenerator.cs @@ -0,0 +1,343 @@ +// 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.Collections.Immutable; +using System.Runtime.CompilerServices; +using System.Text; +using ReactiveUI.Binding.SourceGenerators.Models; + +using static ReactiveUI.Binding.SourceGenerators.CodeGeneration.GeneratedTypeNames; + +namespace ReactiveUI.Binding.SourceGenerators.CodeGeneration; + +/// +/// Generates concrete typed overloads and the workers behind them for InvokeCommand invocations. The +/// command is reached by observing a property path, and each value the receiver produces is offered to whichever +/// command that path currently holds. +/// +internal static class InvokeCommandCodeGenerator +{ + /// The generated worker each dispatch branch hands the invocation to. + private const string WorkerMethodPrefix = "__InvokeCommand_"; + + /// The variable the observed command is assigned to inside a worker. + private const string CommandVariable = "commandObs"; + + /// The parameter carrying the text of the selector naming the command. + private const string CommandExpressionParameter = "commandPropertyExpression"; + + /// The objects a worker takes, in its own parameter order. + private const string WorkerArguments = "source, target"; + + /// The indentation and arrow an interceptor forwards to its worker behind. + private const string ForwardingBodyPrefix = " => "; + + /// Generates concrete typed overloads and workers for InvokeCommand invocations. + /// All detected InvokeCommand invocations. + /// All detected class binding info, for the observed type's mechanism. + /// The consumer compilation's C# language-feature snapshot. + /// Generated source code string, or null if no invocations. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static string? Generate( + ImmutableArray invocations, + ImmutableArray allClasses, + in LanguageFeatures features) => + CodeGeneratorHelpers.GenerateDispatchFile( + invocations, + features, + GroupByTypeSignature, + (sb, group, snapshot) => EmitGroup(sb, group, allClasses, snapshot)); + + /// Groups invocations by the types their overload declares. + /// The InvokeCommand invocations to group. + /// A list of groups, each sharing one overload signature. + /// + /// The selector's type is the stub's own ICommand rather than the command property's declared type, so + /// only the observed value type and the target type shape an overload - a view model exposing a + /// ReactiveCommand and one exposing a plain ICommand are served by the same one. + /// + internal static List GroupByTypeSignature( + ImmutableArray invocations) + { + var groupMap = new Dictionary>(invocations.Length); + var keySb = new PooledStringBuilder(CodeGeneratorHelpers.FragmentBufferCapacity); + + for (var i = 0; i < invocations.Length; i++) + { + var inv = invocations[i]; + _ = keySb.Clear() + .Append(inv.SourceValueTypeFullName).Append('|') + .Append(inv.TargetTypeFullName); + + var key = keySb.ToString(); + + if (!groupMap.TryGetValue(key, out var list)) + { + list = []; + groupMap[key] = list; + } + + list.Add(inv); + } + + keySb.Return(); + + var result = new List(); + foreach (var kvp in groupMap) + { + var first = kvp.Value[0]; + result.Add(new(first.SourceValueTypeFullName, first.TargetTypeFullName, [.. kvp.Value])); + } + + return result; + } + + /// Emits one group: the way its call sites are reached, and one worker per distinct command path. + /// The string builder to append to. + /// The group being emitted. + /// All detected class binding info. + /// The consumer compilation's language-feature snapshot. + /// + /// Call sites spelling the same selector reach the same worker, so the worker is keyed by the target type and + /// that text rather than by the call site. Under expression-text dispatch their branches are identical too, + /// and all but the first would be unreachable, so the group is collapsed to one call site per distinct + /// selector. Interception claims each call site by name, so nothing is collapsed there: a dropped call site + /// would carry no attribute and lose its generated invocation. + /// + private static void EmitGroup( + StringBuilder sb, + InvokeCommandTypeGroup group, + ImmutableArray allClasses, + in LanguageFeatures features) + { + var collapsible = features.SupportsCallerArgExpr && !features.SupportsInterceptors; + var emitted = collapsible + ? group with + { + Invocations = CodeGeneratorHelpers.CollapseIndistinguishableCallSites( + group.Invocations, + static x => x.CommandExpressionText), + } + : group; + + if (features.SupportsInterceptors) + { + GenerateInterceptors(sb, emitted, in features); + } + else + { + GenerateConcreteOverload(sb, emitted, in features); + } + + _ = sb.AppendLine(); + + var emittedWorkers = new HashSet(StringComparer.Ordinal); + for (var i = 0; i < emitted.Invocations.Length; i++) + { + var inv = emitted.Invocations[i]; + var suffix = WorkerSuffix(inv); + if (!emittedWorkers.Add(suffix)) + { + continue; + } + + GenerateWorker(sb, inv, allClasses, suffix); + } + } + + /// Emits the concrete overload the call sites of one group resolve to. + /// The string builder to append to. + /// The group being emitted. + /// The consumer compilation's language-feature snapshot. + private static void GenerateConcreteOverload( + StringBuilder sb, + InvokeCommandTypeGroup group, + in LanguageFeatures features) + { + var dispatchesOnExpressionText = features.SupportsCallerArgExpr; + + CodeGeneratorHelpers.AppendDispatchSummary( + sb, + Constants.InvokeCommandMethodName, + ObservableOf(group.SourceValueTypeFullName), + group.TargetTypeFullName, + dispatchesOnExpressionText); + + _ = sb.Append(" public static ").Append(GeneratedTypeNames.IDisposable).Append(' ') + .Append(Constants.InvokeCommandMethodName).AppendLine("("); + + AppendParameterList(sb, group, dispatchesOnExpressionText, features.SupportsNullable, features.StubHasExpressionParameters); + + _ = sb.AppendLine(GeneratedSyntax.MemberBodyOpen); + + if (dispatchesOnExpressionText) + { + CodeGeneratorHelpers.AppendStaticPrefixNormalization(sb, CommandExpressionParameter); + _ = sb.AppendLine(); + } + + for (var i = 0; i < group.Invocations.Length; i++) + { + var inv = group.Invocations[i]; + var condition = CodeGeneratorHelpers.ConditionKeyword(i); + + if (dispatchesOnExpressionText) + { + _ = sb.Append(CodeGeneratorHelpers.ParameterIndent).Append(condition).Append(" (") + .Append(CommandExpressionParameter).Append(" == \"") + .Append(CodeGeneratorHelpers.EscapeString(inv.CommandExpressionText)).AppendLine("\")") + .AppendLine(GeneratedSyntax.StatementBlockOpen); + } + else + { + CodeGeneratorHelpers.AppendCallerInfoDispatchCondition( + sb, + condition, + inv.CallerLineNumber, + CodeGeneratorHelpers.ComputePathSuffix(inv.CallerFilePath)); + } + + CodeGeneratorHelpers.AppendDispatchReturn(sb, WorkerMethodPrefix + WorkerSuffix(inv), WorkerArguments); + } + + CodeGeneratorHelpers.AppendBindingDispatchFallthrough(sb); + } + + /// Emits one interceptor per worker, claiming every call site that reaches it. + /// The string builder to append to. + /// The group whose call sites are being claimed. + /// The consumer compilation's language-feature snapshot. + private static void GenerateInterceptors( + StringBuilder sb, + InvokeCommandTypeGroup group, + in LanguageFeatures features) + { + var dispatchesOnExpressionText = features.SupportsCallerArgExpr; + var supportsNullable = features.SupportsNullable; + var stubHasExpressionParameters = features.StubHasExpressionParameters; + + foreach (var entry in InterceptorEmitter.GroupCallSites(group.Invocations, static x => x.Interceptor, WorkerSuffix)) + { + foreach (var callSite in entry.Value) + { + InterceptorEmitter.AppendAttribute(sb, callSite.Interceptor, InterceptorEmitter.MemberIndent); + } + + _ = sb.Append(" internal static ").Append(GeneratedTypeNames.IDisposable).Append(" __Intercept_") + .Append(Constants.InvokeCommandMethodName).Append('_').Append(entry.Key).AppendLine("("); + + AppendParameterList(sb, group, dispatchesOnExpressionText, supportsNullable, stubHasExpressionParameters); + + _ = sb.Append(ForwardingBodyPrefix).Append(WorkerMethodPrefix).Append(entry.Key) + .Append('(').Append(WorkerArguments).AppendLine(");").AppendLine(); + } + } + + /// Writes the parameters an InvokeCommand member declares, closing the list. + /// The string builder to append to. + /// The group whose types the parameters are written from. + /// Whether the captured expression text is what identifies a call site. + /// Whether the target supports nullable reference types (C# 8+). + /// Whether the runtime stub declares the expression parameter. + /// + /// One list serves the overload and the interceptor, because both have to be the stub's signature: the + /// overload only wins resolution against a candidate it is otherwise indistinguishable from, and an + /// interceptor is refused outright unless its signature is the intercepted method's. The selector is the + /// stub's ICommand whatever the command property is declared as, for the same reason. + /// + private static void AppendParameterList( + StringBuilder sb, + InvokeCommandTypeGroup group, + bool dispatchesOnExpressionText, + bool supportsNullable, + bool stubHasExpressionParameters) + { + var commandType = supportsNullable ? $"{ICommand}?" : ICommand; + + _ = sb.Append(" this ").Append(ObservableOf(group.SourceValueTypeFullName)).AppendLine(" source,") + .Append(CodeGeneratorHelpers.ParameterIndent).Append(group.TargetTypeFullName).AppendLine(" target,") + .Append(CodeGeneratorHelpers.ParameterIndent) + .Append(PropertyExpression(group.TargetTypeFullName, commandType)).AppendLine(" commandProperty,"); + + if (dispatchesOnExpressionText || stubHasExpressionParameters) + { + CodeGeneratorHelpers.AppendExpressionParameter( + sb, + "commandProperty", + CommandExpressionParameter, + dispatchesOnExpressionText); + } + + _ = sb.AppendLine(CodeGeneratorHelpers.CallerInfoParameterList); + } + + /// Emits the worker that observes the command and offers it each value. + /// The string builder to append to. + /// The call site being emitted. + /// All detected class binding info. + /// The stable method-name suffix for this worker. + /// + /// An absent target declares no property to observe, so it yields a subscription that does nothing rather + /// than a fault - the same outcome as a target whose command property is null, which is the ordinary state + /// before a view model is assigned. + /// + private static void GenerateWorker( + StringBuilder sb, + InvokeCommandInvocationInfo inv, + ImmutableArray allClasses, + string suffix) + { + var classInfo = CodeGeneratorHelpers.ResolveObservedTypeInfo( + allClasses, + inv.TargetTypeFullName, + inv.CommandPropertyPath); + + _ = sb.Append(" private static ").Append(GeneratedTypeNames.IDisposable).Append(' ').Append(WorkerMethodPrefix).Append(suffix).AppendLine("(") + .Append(CodeGeneratorHelpers.ParameterIndent).Append(ObservableOf(inv.SourceValueTypeFullName)).AppendLine(" source,") + .Append(CodeGeneratorHelpers.ParameterIndent).Append(inv.TargetTypeFullName).AppendLine(" target)") + .AppendLine(GeneratedSyntax.MemberBodyOpen) + .Append(" // InvokeCommand: values -> ") + .AppendLine(CodeGeneratorHelpers.BuildPropertyPathString(inv.CommandPropertyPath)) + .AppendLine(" if (target == null)") + .AppendLine(GeneratedSyntax.StatementBlockOpen) + .Append(" return ").Append(EmptyDisposable).AppendLine(".Instance;") + .AppendLine(GeneratedSyntax.StatementBlockClose) + .AppendLine(); + + ObservationCodeGenerator.EmitInlineObservation( + sb, + "target", + inv.CommandPropertyPath, + inv.CommandPropertyPath[inv.CommandPropertyPath.Length - 1].PropertyTypeFullName, + classInfo, + CommandVariable); + + _ = sb.Append(" return ").Append(CommandInvoker).Append(".Invoke(source, ").Append(CommandVariable).AppendLine(");") + .AppendLine(GeneratedSyntax.MemberBodyClose).AppendLine(); + } + + /// Names the worker a call site reaches. + /// The call site. + /// The stable method-name suffix. + /// + /// Keyed by the target type and the selector as written, not by the call site: two call sites spelling the + /// same selector against the same type observe the same path, so one worker serves both. + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static string WorkerSuffix(InvokeCommandInvocationInfo inv) => + CodeGeneratorHelpers.ComputeStableMethodSuffix( + inv.TargetTypeFullName, + string.Empty, + 0, + inv.CommandExpressionText); + + /// Groups InvokeCommand invocations sharing the observed value type and the target type. + /// The fully qualified observable value type. + /// The fully qualified type declaring the command property. + /// All invocations sharing this overload signature. + internal sealed record InvokeCommandTypeGroup( + string SourceValueTypeFullName, + string TargetTypeFullName, + InvokeCommandInvocationInfo[] Invocations); +} diff --git a/src/ReactiveUI.Binding.SourceGenerators/Constants.cs b/src/ReactiveUI.Binding.SourceGenerators/Constants.cs index d54c937e..39e5220d 100644 --- a/src/ReactiveUI.Binding.SourceGenerators/Constants.cs +++ b/src/ReactiveUI.Binding.SourceGenerators/Constants.cs @@ -136,6 +136,9 @@ internal static class Constants /// Method name for binding an observable stream to a target property (BindTo). internal const string BindToMethodName = "BindTo"; + /// Method name for executing a command with each value a stream produces (InvokeCommand). + internal const string InvokeCommandMethodName = "InvokeCommand"; + /// Metadata name for the open generic IViewFor<T> interface used for view resolution. internal const string IViewForGenericMetadataName = "ReactiveUI.Binding.IViewFor`1"; diff --git a/src/ReactiveUI.Binding.SourceGenerators/Helpers/InvokeCommandExtractor.cs b/src/ReactiveUI.Binding.SourceGenerators/Helpers/InvokeCommandExtractor.cs new file mode 100644 index 00000000..ecb2ab28 --- /dev/null +++ b/src/ReactiveUI.Binding.SourceGenerators/Helpers/InvokeCommandExtractor.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 Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using ReactiveUI.Binding.SourceGenerators.Models; + +namespace ReactiveUI.Binding.SourceGenerators.Helpers; + +/// +/// Extracts from InvokeCommand invocations. The values come +/// from the receiver, so the only path extracted is the one reaching the command. +/// +internal static class InvokeCommandExtractor +{ + /// The minimum number of arguments this overload carries (target, command property). + private const int MinimumInvokeCommandArgumentCount = 2; + + /// Pipeline B transform: extracts from an InvokeCommand invocation. + /// The generator syntax context. + /// Cancellation token. + /// An POCO, or null if the invocation is not analyzable. + /// + /// The overload taking the command itself has nothing to resolve and nothing to observe, so it carries no + /// selector and is declined here: it falls short of the argument count, and its only argument is not a + /// lambda a path could be read from. + /// + internal static InvokeCommandInvocationInfo? ExtractInvokeCommandInvocation( + GeneratorSyntaxContext context, + CancellationToken ct) + { + var invocation = (InvocationExpressionSyntax)context.Node; + var memberAccess = (MemberAccessExpressionSyntax)invocation.Expression; + + var semanticModel = context.SemanticModel; + var methodSymbol = ExtractorValidation.ExtractMethodSymbol(semanticModel.GetSymbolInfo(invocation, ct)); + if (methodSymbol is null) + { + return null; + } + + if (!ExtractorValidation.IsRecognizedExtensionClass(methodSymbol.ContainingType)) + { + return null; + } + + var args = invocation.ArgumentList.Arguments; + if (!ExtractorValidation.HasMinimumArguments(args.Count, MinimumInvokeCommandArgumentCount)) + { + return null; + } + + // The values offered as the command parameter are the T of the receiver's IObservable. + var sourceValueType = BindToExtractor.GetObservableValueType( + semanticModel.GetTypeInfo(memberAccess.Expression, ct).Type); + if (sourceValueType is null) + { + return null; + } + + var commandArg = args[1].Expression; + var commandPropertyPath = SyntaxHelpers.ExtractPropertyPathFromLambda(commandArg, semanticModel, ct); + var targetTypeName = + ExtractorValidation.GetDeclarableTypeDisplayName(semanticModel.GetTypeInfo(args[0].Expression, ct).Type); + + // One guard for both: a target the model could not name is as unusable as a path it could not read. + return commandPropertyPath is null || commandPropertyPath.Length == 0 || targetTypeName is null + ? null + : new InvokeCommandInvocationInfo( + invocation.SyntaxTree.FilePath, + invocation.GetLocation().GetLineSpan().StartLinePosition.Line + 1, + sourceValueType.ToDisplayString(SymbolDisplayFormat.FullyQualifiedFormat), + targetTypeName, + new(commandPropertyPath), + CodeGeneration.CodeGeneratorHelpers.NormalizeLambdaText(commandArg.ToString()), + InterceptableLocationReader.Read(semanticModel, invocation, ct)); + } +} diff --git a/src/ReactiveUI.Binding.SourceGenerators/Invocations/InvokeCommandInvocationGenerator.cs b/src/ReactiveUI.Binding.SourceGenerators/Invocations/InvokeCommandInvocationGenerator.cs new file mode 100644 index 00000000..9fc50f2f --- /dev/null +++ b/src/ReactiveUI.Binding.SourceGenerators/Invocations/InvokeCommandInvocationGenerator.cs @@ -0,0 +1,42 @@ +// 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; +using ReactiveUI.Binding.SourceGenerators.CodeGeneration; +using ReactiveUI.Binding.SourceGenerators.Models; + +namespace ReactiveUI.Binding.SourceGenerators.Invocations; + +/// Detects InvokeCommand invocations and generates per-invocation command execution code. +internal static class InvokeCommandInvocationGenerator +{ + /// Registers the InvokeCommand invocation detection pipeline. + /// The generator initialization context. + /// The detected invocations of this API. + /// The shared type detection pipeline. + /// The consumer compilation's C# language-feature snapshot. + internal static void Register( + in IncrementalGeneratorInitializationContext context, + IncrementalValuesProvider invocations, + IncrementalValuesProvider allClasses, + IncrementalValueProvider languageFeatures) + { + var combined = invocations.Collect() + .Combine(allClasses.Collect()) + .Combine(languageFeatures); + + context.RegisterSourceOutput( + combined, + static (ctx, data) => + { + var source = InvokeCommandCodeGenerator.Generate(data.Left.Left, data.Left.Right, data.Right); + if (source is null) + { + return; + } + + CodeGeneratorHelpers.AddGeneratedSource(ctx, "InvokeCommandDispatch.g.cs", source, data.Right); + }); + } +} diff --git a/src/ReactiveUI.Binding.SourceGenerators/Models/InvokeCommandInvocationInfo.cs b/src/ReactiveUI.Binding.SourceGenerators/Models/InvokeCommandInvocationInfo.cs new file mode 100644 index 00000000..9edc90c7 --- /dev/null +++ b/src/ReactiveUI.Binding.SourceGenerators/Models/InvokeCommandInvocationInfo.cs @@ -0,0 +1,28 @@ +// 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. + +namespace ReactiveUI.Binding.SourceGenerators.Models; + +/// +/// Per-call-site value-equatable POCO for InvokeCommand invocations. The values come from an observable +/// stream, so the only path captured is the one reaching the command. Contains no ISymbol, SyntaxNode, or +/// Location references. +/// +/// The source file path of the call site, captured via [CallerFilePath]. +/// The line number of the call site, captured via [CallerLineNumber]. +/// The fully qualified type produced by the source observable (the T in IObservable<T>). +/// The fully qualified name of the type declaring the command property. +/// The property path chain reaching the command. +/// The original expression text of the command lambda argument. +/// +/// Where this call site is, for a build that claims call sites outright rather than competing for them. +/// +internal sealed record InvokeCommandInvocationInfo( + string CallerFilePath, + int CallerLineNumber, + string SourceValueTypeFullName, + string TargetTypeFullName, + EquatableArray CommandPropertyPath, + string CommandExpressionText, + InterceptorLocation Interceptor = default); diff --git a/src/ReactiveUI.Binding.SourceGenerators/RoslynHelpers.cs b/src/ReactiveUI.Binding.SourceGenerators/RoslynHelpers.cs index 42825924..f2f28933 100644 --- a/src/ReactiveUI.Binding.SourceGenerators/RoslynHelpers.cs +++ b/src/ReactiveUI.Binding.SourceGenerators/RoslynHelpers.cs @@ -174,4 +174,16 @@ node is InvocationExpressionSyntax invocation { Name.Identifier.Text: Constants.BindToMethodName }; + + /// Pipeline B predicate: detects InvokeCommand invocations (observable stream to a command). + /// The syntax node to check. + /// Cancellation token. + /// true if the node is an InvokeCommand invocation; otherwise, false. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static bool IsInvokeCommandInvocation(SyntaxNode node, CancellationToken ct) => + node is InvocationExpressionSyntax invocation + && invocation.Expression is MemberAccessExpressionSyntax + { + Name.Identifier.Text: Constants.InvokeCommandMethodName + }; } diff --git a/src/tests/ReactiveUI.Binding.Analyzer.Tests/MixinShadowAnalyzerTests.cs b/src/tests/ReactiveUI.Binding.Analyzer.Tests/MixinShadowAnalyzerTests.cs index c0d1559d..5843459c 100644 --- a/src/tests/ReactiveUI.Binding.Analyzer.Tests/MixinShadowAnalyzerTests.cs +++ b/src/tests/ReactiveUI.Binding.Analyzer.Tests/MixinShadowAnalyzerTests.cs @@ -171,6 +171,17 @@ public async Task MixinCall_WithTheBindingPackageReferenced_IsReported() await Assert.That(diagnostics.Count(static d => d.Id == DiagnosticId)).IsEqualTo(1); } + /// An InvokeCommand answered by ReactiveUI's mixin is reported like any other generated API. + /// A task representing the asynchronous test operation. + [Test] + public async Task MixinCall_ToInvokeCommand_IsReported() + { + var diagnostics = await AnalyzerTestHelper.GetDiagnosticsAsync( + Source(ReactiveUiNamespace, "InvokeCommand", BindingPackage)); + + await Assert.That(diagnostics.Count(static d => d.Id == DiagnosticId)).IsEqualTo(1); + } + /// Without this package there is no generated overload to have lost, so nothing is reported. /// A task representing the asynchronous test operation. [Test] diff --git a/src/tests/ReactiveUI.Binding.Analyzer.Tests/TypeAnalyzerTests.InvokeCommand.cs b/src/tests/ReactiveUI.Binding.Analyzer.Tests/TypeAnalyzerTests.InvokeCommand.cs new file mode 100644 index 00000000..b68063a4 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.Analyzer.Tests/TypeAnalyzerTests.InvokeCommand.cs @@ -0,0 +1,100 @@ +// 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 ReactiveUI.Binding.Analyzer.Analyzers; +using ReactiveUI.Binding.Analyzer.Tests.Helpers; + +namespace ReactiveUI.Binding.Analyzer.Tests; + +/// +/// Tests for over InvokeCommand, whose observed object is not the type +/// argument every other API names it with: the first one is the value type of a stream the caller already built. +/// +public partial class TypeAnalyzerTests +{ + /// The stub class as it declares InvokeCommand. + private const string InvokeCommandPreamble = """ + using System; + using System.ComponentModel; + using System.Linq.Expressions; + using System.Windows.Input; + + namespace ReactiveUI.Binding + { + public static class __ReactiveUIGeneratedBindings + { + public static IDisposable InvokeCommand( + this IObservable source, + TTarget target, + Expression> commandProperty) + where TTarget : class + => throw new NotImplementedException(); + } + } + """; + + /// The type holding the command is the one the diagnostic is about. + /// A task representing the asynchronous test operation. + [Test] + public async Task RXUIBIND002_InvokeCommandOnATypeThatDoesNotNotify_ReportsTheTargetType() + { + const string Source = InvokeCommandPreamble + """ + + namespace TestApp + { + public class PlainObject + { + public ICommand Save { get; set; } = null!; + } + + public class Usage + { + public void Test(IObservable values) + { + var obj = new PlainObject(); + ReactiveUI.Binding.__ReactiveUIGeneratedBindings.InvokeCommand(values, obj, x => x.Save); + } + } + } + """; + + var diagnostics = await AnalyzerTestHelper.GetDiagnosticsAsync(Source); + + await Assert.That(diagnostics.Length).IsEqualTo(1); + await Assert.That(diagnostics[0].Id).IsEqualTo(NoObservablePropertiesDiagnosticId); + await Assert.That(diagnostics[0].GetMessage()).Contains("PlainObject"); + } + + /// A notifying target reports nothing, and the stream's value type is not mistaken for it. + /// A task representing the asynchronous test operation. + [Test] + public async Task RXUIBIND002_InvokeCommandOnANotifyingType_NoDiagnostic() + { + const string Source = InvokeCommandPreamble + """ + + namespace TestApp + { + public class MyViewModel : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + + public ICommand Save { get; set; } = null!; + } + + public class Usage + { + public void Test(IObservable values) + { + var viewModel = new MyViewModel(); + ReactiveUI.Binding.__ReactiveUIGeneratedBindings.InvokeCommand(values, viewModel, x => x.Save); + } + } + } + """; + + var diagnostics = await AnalyzerTestHelper.GetDiagnosticsAsync(Source); + + await Assert.That(diagnostics.Any(static d => d.Id == NoObservablePropertiesDiagnosticId)).IsFalse(); + } +} diff --git a/src/tests/ReactiveUI.Binding.Analyzer.Tests/TypeAnalyzerTests.cs b/src/tests/ReactiveUI.Binding.Analyzer.Tests/TypeAnalyzerTests.cs index a97c562e..9a29082d 100644 --- a/src/tests/ReactiveUI.Binding.Analyzer.Tests/TypeAnalyzerTests.cs +++ b/src/tests/ReactiveUI.Binding.Analyzer.Tests/TypeAnalyzerTests.cs @@ -8,7 +8,7 @@ namespace ReactiveUI.Binding.Analyzer.Tests; /// Tests for . -public class TypeAnalyzerTests +public partial class TypeAnalyzerTests { /// The diagnostic id reported when a type has no observable properties. private const string NoObservablePropertiesDiagnosticId = "RXUIBIND002"; diff --git a/src/tests/ReactiveUI.Binding.AotValidation/AotCommand.cs b/src/tests/ReactiveUI.Binding.AotValidation/AotCommand.cs new file mode 100644 index 00000000..624b5718 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.AotValidation/AotCommand.cs @@ -0,0 +1,37 @@ +// 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; +using System.Runtime.CompilerServices; +using System.Windows.Input; + +namespace ReactiveUI.Binding.AotValidation; + +/// A command that counts its executions, so a validation scenario can assert it ran. +public sealed class AotCommand : ICommand +{ + /// + public event EventHandler? CanExecuteChanged + { + add { /* CanExecute is asked per emission rather than tracked from the event. */ } + remove { /* CanExecute is asked per emission rather than tracked from the event. */ } + } + + /// Gets the number of times this command was executed. + public int ExecuteCount { get; private set; } + + /// Gets the parameter of the most recent execution. + public object? LastParameter { get; private set; } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool CanExecute(object? parameter) => true; + + /// + public void Execute(object? parameter) + { + ExecuteCount++; + LastParameter = parameter; + } +} diff --git a/src/tests/ReactiveUI.Binding.AotValidation/AotViewModel.cs b/src/tests/ReactiveUI.Binding.AotValidation/AotViewModel.cs index 0ea986f0..6e9e8fc4 100644 --- a/src/tests/ReactiveUI.Binding.AotValidation/AotViewModel.cs +++ b/src/tests/ReactiveUI.Binding.AotValidation/AotViewModel.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for full license information. using System.ComponentModel; +using System.Windows.Input; namespace ReactiveUI.Binding.AotValidation; @@ -65,4 +66,21 @@ public AotChildViewModel Child PropertyChanged?.Invoke(this, new(nameof(Child))); } } = new(); + + /// Gets or sets the command a stream of values is invoked against. + public ICommand? Save + { + get => field; + set + { + if (field == value) + { + return; + } + + PropertyChanging?.Invoke(this, new(nameof(Save))); + field = value; + PropertyChanged?.Invoke(this, new(nameof(Save))); + } + } } diff --git a/src/tests/ReactiveUI.Binding.AotValidation/Program.cs b/src/tests/ReactiveUI.Binding.AotValidation/Program.cs index 639dfa42..a1a1c111 100644 --- a/src/tests/ReactiveUI.Binding.AotValidation/Program.cs +++ b/src/tests/ReactiveUI.Binding.AotValidation/Program.cs @@ -32,6 +32,9 @@ internal static class Program /// The value set before the binding is disposed, which must survive the disposal. private const string BeforeDisposal = "Before"; + /// The number of executions expected after the initial value and one change. + private const int ExpectedTwoExecutions = 2; + /// /// Sink for scenario results. This validation harness runs standalone under Native AOT with no host /// or logging infrastructure, so its report goes straight to the process output stream. @@ -57,6 +60,7 @@ internal static int Main() ValidateBindOneWayDisposal(); ValidateOneWayBind(); ValidateBind(); + ValidateInvokeCommand(); Report(string.Empty); Report($"AOT Validation: {_passed} passed, {_failed} failed"); @@ -169,6 +173,22 @@ private static void ValidateBind() AssertEqual("Bind view to view model", "FromView", viewModel.Name); } + /// InvokeCommand executes the command a view model holds with each observed value. + private static void ValidateInvokeCommand() + { + var viewModel = new AotViewModel { Name = InitialName }; + var command = new AotCommand(); + viewModel.Save = command; + + using var invocation = viewModel.WhenChanged(x => x.Name).InvokeCommand(viewModel, x => x.Save); + AssertEqual("InvokeCommand initial", 1, command.ExecuteCount); + AssertEqual("InvokeCommand initial parameter", InitialName, command.LastParameter as string); + + viewModel.Name = ReplacementName; + AssertEqual("InvokeCommand after set", ExpectedTwoExecutions, command.ExecuteCount); + AssertEqual("InvokeCommand parameter after set", ReplacementName, command.LastParameter as string); + } + /// Compares an expected and actual value, recording a pass or failure to the console. /// The value type being compared. /// A human-readable label for the scenario. diff --git a/src/tests/ReactiveUI.Binding.GeneratedCode.TestModels/Scenarios/InvokeCommandScenarios.cs b/src/tests/ReactiveUI.Binding.GeneratedCode.TestModels/Scenarios/InvokeCommandScenarios.cs new file mode 100644 index 00000000..2e7c7a2f --- /dev/null +++ b/src/tests/ReactiveUI.Binding.GeneratedCode.TestModels/Scenarios/InvokeCommandScenarios.cs @@ -0,0 +1,32 @@ +// 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; +using System.Runtime.CompilerServices; + +namespace ReactiveUI.Binding.GeneratedCode.TestModels.Scenarios; + +/// Scenario methods for InvokeCommand that the source generator processes at compile time. +public static class InvokeCommandScenarios +{ + /// Offers each value the stream produces to the command a view model property holds. + /// The values driving the executions. + /// The view model holding the command. + /// A disposable that stops executing the command. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IDisposable CommandProperty( + IObservable values, + SharedScenarios.InvokeCommand.CommandProperty.MyViewModel viewModel) => + SharedScenarios.InvokeCommand.CommandProperty.Scenario.Execute(values, viewModel); + + /// Offers each value the stream produces to the command reached through a chain. + /// The values driving the executions. + /// The view model whose child holds the command. + /// A disposable that stops executing the command. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IDisposable DeepCommandPath( + IObservable values, + SharedScenarios.InvokeCommand.DeepCommandPath.MyViewModel viewModel) => + SharedScenarios.InvokeCommand.DeepCommandPath.Scenario.Execute(values, viewModel); +} diff --git a/src/tests/ReactiveUI.Binding.GeneratedCode.Tests/Binding/InvokeCommandTests.cs b/src/tests/ReactiveUI.Binding.GeneratedCode.Tests/Binding/InvokeCommandTests.cs new file mode 100644 index 00000000..f15ed61a --- /dev/null +++ b/src/tests/ReactiveUI.Binding.GeneratedCode.Tests/Binding/InvokeCommandTests.cs @@ -0,0 +1,176 @@ +// 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.Reactive.Subjects; +using System.Runtime.CompilerServices; +using System.Windows.Input; +using ReactiveUI.Binding.GeneratedCode.TestModels.Scenarios; + +namespace ReactiveUI.Binding.GeneratedCode.Tests.Binding; + +/// Tests that the generated InvokeCommand code executes the command at runtime. +public class InvokeCommandTests +{ + /// The value the stream is primed with. + private const string InitialValue = "initial"; + + /// The value the stream produces after the first one. + private const string SecondValue = "second"; + + /// The number of executions a test expects after offering two values. + private const int TwoExecutions = 2; + + /// The first value the chain test offers. + private const int FirstNumber = 1; + + /// The value the chain test offers after replacing the child. + private const int SecondNumber = 2; + + /// Verifies each value the stream produces is offered to the command as its parameter. + /// A task representing the asynchronous test operation. + [Test] + public async Task CommandProperty_ExecutesWithEachValue() + { + var viewModel = new SharedScenarios.InvokeCommand.CommandProperty.MyViewModel(); + var command = new TrackingCommand(); + viewModel.Save = command; + var values = new Subject(); + + using var invocation = InvokeCommandScenarios.CommandProperty(values, viewModel); + + values.OnNext(InitialValue); + values.OnNext(SecondValue); + + await Assert.That(command.ExecuteCount).IsEqualTo(TwoExecutions); + await Assert.That(command.LastParameter).IsEqualTo(SecondValue); + } + + /// Verifies a command assigned after the subscription still receives the values. + /// A task representing the asynchronous test operation. + [Test] + public async Task CommandProperty_CommandAssignedAfterSubscribing_Executes() + { + var viewModel = new SharedScenarios.InvokeCommand.CommandProperty.MyViewModel(); + var command = new TrackingCommand(); + var values = new Subject(); + + using var invocation = InvokeCommandScenarios.CommandProperty(values, viewModel); + + viewModel.Save = command; + values.OnNext(InitialValue); + + await Assert.That(command.ExecuteCount).IsEqualTo(1); + } + + /// Verifies a value a command refuses is dropped rather than executed or held. + /// A task representing the asynchronous test operation. + [Test] + public async Task CommandProperty_CommandRefusesTheValue_DoesNotExecute() + { + var viewModel = new SharedScenarios.InvokeCommand.CommandProperty.MyViewModel(); + var command = new TrackingCommand { CanExecuteResult = false }; + viewModel.Save = command; + var values = new Subject(); + + using var invocation = InvokeCommandScenarios.CommandProperty(values, viewModel); + + values.OnNext(InitialValue); + + await Assert.That(command.ExecuteCount).IsEqualTo(0); + + command.CanExecuteResult = true; + values.OnNext(SecondValue); + + await Assert.That(command.ExecuteCount).IsEqualTo(1); + } + + /// Verifies a view model with no command assigned drops the values it is offered. + /// A task representing the asynchronous test operation. + [Test] + public async Task CommandProperty_NoCommandAssigned_DropsTheValues() + { + var viewModel = new SharedScenarios.InvokeCommand.CommandProperty.MyViewModel(); + var values = new Subject(); + + using var invocation = InvokeCommandScenarios.CommandProperty(values, viewModel); + + values.OnNext(InitialValue); + + await Assert.That(viewModel.Save).IsNull(); + } + + /// Verifies disposing the invocation stops executing the command. + /// A task representing the asynchronous test operation. + [Test] + public async Task CommandProperty_Disposed_StopsExecuting() + { + var viewModel = new SharedScenarios.InvokeCommand.CommandProperty.MyViewModel(); + var command = new TrackingCommand(); + viewModel.Save = command; + var values = new Subject(); + + var invocation = InvokeCommandScenarios.CommandProperty(values, viewModel); + invocation.Dispose(); + + values.OnNext(InitialValue); + + await Assert.That(command.ExecuteCount).IsEqualTo(0); + } + + /// Verifies the command is reached through the chain, and followed when the chain changes. + /// A task representing the asynchronous test operation. + [Test] + public async Task DeepCommandPath_FollowsTheChain() + { + var viewModel = new SharedScenarios.InvokeCommand.DeepCommandPath.MyViewModel(); + var first = new TrackingCommand(); + var second = new TrackingCommand(); + viewModel.Child = new SharedScenarios.InvokeCommand.DeepCommandPath.ChildViewModel { Save = first }; + var values = new Subject(); + + using var invocation = InvokeCommandScenarios.DeepCommandPath(values, viewModel); + + values.OnNext(FirstNumber); + + await Assert.That(first.ExecuteCount).IsEqualTo(1); + await Assert.That(first.LastParameter).IsEqualTo(FirstNumber); + + viewModel.Child = new SharedScenarios.InvokeCommand.DeepCommandPath.ChildViewModel { Save = second }; + values.OnNext(SecondNumber); + + await Assert.That(first.ExecuteCount).IsEqualTo(1); + await Assert.That(second.ExecuteCount).IsEqualTo(1); + } + + /// An that records what it was offered. + private sealed class TrackingCommand : ICommand + { + /// + public event EventHandler? CanExecuteChanged + { + add { /* CanExecute is read per emission rather than tracked. */ } + remove { /* CanExecute is read per emission rather than tracked. */ } + } + + /// Gets or sets a value indicating whether returns . + public bool CanExecuteResult { get; set; } = true; + + /// Gets the number of times has been called. + public int ExecuteCount { get; private set; } + + /// Gets the parameter passed to the most recent call. + public object? LastParameter { get; private set; } + + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public bool CanExecute(object? parameter) => CanExecuteResult; + + /// + public void Execute(object? parameter) + { + ExecuteCount++; + LastParameter = parameter; + } + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/TestHelper.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/TestHelper.cs index d28427f0..953e1675 100644 --- a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/TestHelper.cs +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/Helpers/TestHelper.cs @@ -493,6 +493,7 @@ public static LoadedAssembly EmitAndLoad(GeneratorTestResult result) "BindInteractionGeneratorTests" => "BIG", "BindCommandGeneratorTests" => "BCG", "BindToGeneratorTests" => "BToG", + "InvokeCommandGeneratorTests" => "ICG", "ViewLocatorDispatchGeneratorTests" => "VDG", _ => typeName }; diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty#GeneratedBinderRegistration.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty#GeneratedBinderRegistration.g.verified.cs new file mode 100644 index 00000000..377a7c11 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty#GeneratedBinderRegistration.g.verified.cs @@ -0,0 +1,25 @@ +//HintName: GeneratedBinderRegistration.g.cs +// +#pragma warning disable +#nullable enable + +namespace ReactiveUI.Binding.Generated +{ + /// + /// Auto-generated binder registration. Registers high-affinity + /// ICreatesObservableForProperty implementations detected at compile time. + /// + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal static class __GeneratedBinderRegistration + { + /// + /// Registers all generated binders with the Splat service locator. + /// + internal static void Initialize() + { + // Generated binder registrations will be added here in future phases. + // Each per-kind binder provides high-affinity observation for detected types. + // Detected types for kind: INPC + } + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty#GeneratedBindingsAttributes.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty#GeneratedBindingsAttributes.g.verified.cs new file mode 100644 index 00000000..283c2ece --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty#GeneratedBindingsAttributes.g.verified.cs @@ -0,0 +1,12 @@ +//HintName: GeneratedBindingsAttributes.g.cs +// +#pragma warning disable +global using global::ReactiveUI.Binding.Generated.TestAssembly; + +namespace ReactiveUI.Binding.Generated.TestAssembly +{ + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal static partial class __ReactiveUIGeneratedBindings + { + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty#InvokeCommandDispatch.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty#InvokeCommandDispatch.g.verified.cs new file mode 100644 index 00000000..99b700b7 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty#InvokeCommandDispatch.g.verified.cs @@ -0,0 +1,66 @@ +//HintName: InvokeCommandDispatch.g.cs +// +#pragma warning disable +#nullable enable + +using System; + +namespace ReactiveUI.Binding.Generated.TestAssembly +{ + internal static partial class __ReactiveUIGeneratedBindings + { + /// + /// Concrete typed overload for InvokeCommand from global::System.IObservable to global::SharedScenarios.InvokeCommand.CommandProperty.MyViewModel. + /// Uses CallerArgumentExpression for dispatch. + /// + public static global::System.IDisposable InvokeCommand( + this global::System.IObservable source, + global::SharedScenarios.InvokeCommand.CommandProperty.MyViewModel target, + global::System.Linq.Expressions.Expression> commandProperty, + [global::System.Runtime.CompilerServices.CallerArgumentExpression("commandProperty")] string commandPropertyExpression = "", + [global::System.Runtime.CompilerServices.CallerFilePath] string callerFilePath = "", + [global::System.Runtime.CompilerServices.CallerLineNumber] int callerLineNumber = 0) + { + commandPropertyExpression = commandPropertyExpression.StartsWith("static ", global::System.StringComparison.Ordinal) + ? commandPropertyExpression.Substring(7) + : commandPropertyExpression; + + if (commandPropertyExpression == "x => x.Save") + { + return __InvokeCommand_7FFFFF3B8638039A(source, target); + } + throw new global::System.InvalidOperationException( + "No generated binding found. Ensure the expression is an inline lambda for compile-time optimization."); + } + + private static global::System.IDisposable __InvokeCommand_7FFFFF3B8638039A( + global::System.IObservable source, + global::SharedScenarios.InvokeCommand.CommandProperty.MyViewModel target) + { + // InvokeCommand: values -> Save + if (target == null) + { + return global::ReactiveUI.Primitives.Disposables.EmptyDisposable.Instance; + } + + var commandObsMechanism = new global::ReactiveUI.Binding.Observables.PropertyObservable( + target, + "Save", + (global::System.ComponentModel.INotifyPropertyChanged __o) => ((global::SharedScenarios.InvokeCommand.CommandProperty.MyViewModel)__o).Save, + true); + var commandObsRegistration = global::ReactiveUI.Binding.Fallback.ObservationAffinityChecker.FindHigherAffinityPlugin(typeof(global::SharedScenarios.InvokeCommand.CommandProperty.MyViewModel), "Save", 5, false); + var commandObs = commandObsRegistration == null + ? (global::System.IObservable)commandObsMechanism + : (global::System.IObservable)new global::ReactiveUI.Binding.Observables.PluginPropertyObservable( + commandObsRegistration, + target, + ((global::System.Linq.Expressions.Expression>)(__e => __e.Save)).Body, + "Save", + (object __o) => ((global::SharedScenarios.InvokeCommand.CommandProperty.MyViewModel)__o).Save, + false, + true); + return global::ReactiveUI.Binding.CommandBinding.CommandInvoker.Invoke(source, commandObs); + } + + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty_CFP#GeneratedBinderRegistration.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty_CFP#GeneratedBinderRegistration.g.verified.cs new file mode 100644 index 00000000..d8ddbc56 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty_CFP#GeneratedBinderRegistration.g.verified.cs @@ -0,0 +1,24 @@ +//HintName: GeneratedBinderRegistration.g.cs +// +#pragma warning disable + +namespace ReactiveUI.Binding.Generated +{ + /// + /// Auto-generated binder registration. Registers high-affinity + /// ICreatesObservableForProperty implementations detected at compile time. + /// + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal static class __GeneratedBinderRegistration + { + /// + /// Registers all generated binders with the Splat service locator. + /// + internal static void Initialize() + { + // Generated binder registrations will be added here in future phases. + // Each per-kind binder provides high-affinity observation for detected types. + // Detected types for kind: INPC + } + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty_CFP#GeneratedBindingsAttributes.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty_CFP#GeneratedBindingsAttributes.g.verified.cs new file mode 100644 index 00000000..7b2e408b --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty_CFP#GeneratedBindingsAttributes.g.verified.cs @@ -0,0 +1,10 @@ +//HintName: GeneratedBindingsAttributes.g.cs +// +#pragma warning disable +namespace ReactiveUI.Binding +{ + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal static partial class __ReactiveUIGeneratedBindings + { + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty_CFP#InvokeCommandDispatch.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty_CFP#InvokeCommandDispatch.g.verified.cs new file mode 100644 index 00000000..25f1c8c4 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.CommandProperty_CFP#InvokeCommandDispatch.g.verified.cs @@ -0,0 +1,62 @@ +//HintName: InvokeCommandDispatch.g.cs +// +#pragma warning disable + +using System; + +namespace ReactiveUI.Binding +{ + internal static partial class __ReactiveUIGeneratedBindings + { + /// + /// Concrete typed overload for InvokeCommand from global::System.IObservable to global::SharedScenarios.InvokeCommand.CommandProperty.MyViewModel. + /// Uses CallerFilePath + CallerLineNumber for dispatch. + /// + public static global::System.IDisposable InvokeCommand( + this global::System.IObservable source, + global::SharedScenarios.InvokeCommand.CommandProperty.MyViewModel target, + global::System.Linq.Expressions.Expression> commandProperty, + string commandPropertyExpression = "", + [global::System.Runtime.CompilerServices.CallerFilePath] string callerFilePath = "", + [global::System.Runtime.CompilerServices.CallerLineNumber] int callerLineNumber = 0) + { + if (callerLineNumber == 49 + && callerFilePath.EndsWith("", global::System.StringComparison.OrdinalIgnoreCase)) + { + return __InvokeCommand_7FFFFF3B8638039A(source, target); + } + throw new global::System.InvalidOperationException( + "No generated binding found. Ensure the expression is an inline lambda for compile-time optimization."); + } + + private static global::System.IDisposable __InvokeCommand_7FFFFF3B8638039A( + global::System.IObservable source, + global::SharedScenarios.InvokeCommand.CommandProperty.MyViewModel target) + { + // InvokeCommand: values -> Save + if (target == null) + { + return global::ReactiveUI.Primitives.Disposables.EmptyDisposable.Instance; + } + + var commandObsMechanism = new global::ReactiveUI.Binding.Observables.PropertyObservable( + target, + "Save", + (global::System.ComponentModel.INotifyPropertyChanged __o) => ((global::SharedScenarios.InvokeCommand.CommandProperty.MyViewModel)__o).Save, + true); + var commandObsRegistration = global::ReactiveUI.Binding.Fallback.ObservationAffinityChecker.FindHigherAffinityPlugin(typeof(global::SharedScenarios.InvokeCommand.CommandProperty.MyViewModel), "Save", 5, false); + var commandObs = commandObsRegistration == null + ? (global::System.IObservable)commandObsMechanism + : (global::System.IObservable)new global::ReactiveUI.Binding.Observables.PluginPropertyObservable( + commandObsRegistration, + target, + ((global::System.Linq.Expressions.Expression>)(__e => __e.Save)).Body, + "Save", + (object __o) => ((global::SharedScenarios.InvokeCommand.CommandProperty.MyViewModel)__o).Save, + false, + true); + return global::ReactiveUI.Binding.CommandBinding.CommandInvoker.Invoke(source, commandObs); + } + + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.DeepCommandPath#GeneratedBinderRegistration.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.DeepCommandPath#GeneratedBinderRegistration.g.verified.cs new file mode 100644 index 00000000..377a7c11 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.DeepCommandPath#GeneratedBinderRegistration.g.verified.cs @@ -0,0 +1,25 @@ +//HintName: GeneratedBinderRegistration.g.cs +// +#pragma warning disable +#nullable enable + +namespace ReactiveUI.Binding.Generated +{ + /// + /// Auto-generated binder registration. Registers high-affinity + /// ICreatesObservableForProperty implementations detected at compile time. + /// + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal static class __GeneratedBinderRegistration + { + /// + /// Registers all generated binders with the Splat service locator. + /// + internal static void Initialize() + { + // Generated binder registrations will be added here in future phases. + // Each per-kind binder provides high-affinity observation for detected types. + // Detected types for kind: INPC + } + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.DeepCommandPath#GeneratedBindingsAttributes.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.DeepCommandPath#GeneratedBindingsAttributes.g.verified.cs new file mode 100644 index 00000000..283c2ece --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.DeepCommandPath#GeneratedBindingsAttributes.g.verified.cs @@ -0,0 +1,12 @@ +//HintName: GeneratedBindingsAttributes.g.cs +// +#pragma warning disable +global using global::ReactiveUI.Binding.Generated.TestAssembly; + +namespace ReactiveUI.Binding.Generated.TestAssembly +{ + [global::System.Diagnostics.CodeAnalysis.ExcludeFromCodeCoverage] + internal static partial class __ReactiveUIGeneratedBindings + { + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.DeepCommandPath#InvokeCommandDispatch.g.verified.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.DeepCommandPath#InvokeCommandDispatch.g.verified.cs new file mode 100644 index 00000000..0fcc2b8c --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ICG.DeepCommandPath#InvokeCommandDispatch.g.verified.cs @@ -0,0 +1,72 @@ +//HintName: InvokeCommandDispatch.g.cs +// +#pragma warning disable +#nullable enable + +using System; + +namespace ReactiveUI.Binding.Generated.TestAssembly +{ + internal static partial class __ReactiveUIGeneratedBindings + { + /// + /// Concrete typed overload for InvokeCommand from global::System.IObservable to global::SharedScenarios.InvokeCommand.DeepCommandPath.MyViewModel. + /// Uses CallerArgumentExpression for dispatch. + /// + public static global::System.IDisposable InvokeCommand( + this global::System.IObservable source, + global::SharedScenarios.InvokeCommand.DeepCommandPath.MyViewModel target, + global::System.Linq.Expressions.Expression> commandProperty, + [global::System.Runtime.CompilerServices.CallerArgumentExpression("commandProperty")] string commandPropertyExpression = "", + [global::System.Runtime.CompilerServices.CallerFilePath] string callerFilePath = "", + [global::System.Runtime.CompilerServices.CallerLineNumber] int callerLineNumber = 0) + { + commandPropertyExpression = commandPropertyExpression.StartsWith("static ", global::System.StringComparison.Ordinal) + ? commandPropertyExpression.Substring(7) + : commandPropertyExpression; + + if (commandPropertyExpression == "x => x.Child.Save") + { + return __InvokeCommand_7FFFF3D031E2997E(source, target); + } + throw new global::System.InvalidOperationException( + "No generated binding found. Ensure the expression is an inline lambda for compile-time optimization."); + } + + private static global::System.IDisposable __InvokeCommand_7FFFF3D031E2997E( + global::System.IObservable source, + global::SharedScenarios.InvokeCommand.DeepCommandPath.MyViewModel target) + { + // InvokeCommand: values -> Child.Save + if (target == null) + { + return global::ReactiveUI.Primitives.Disposables.EmptyDisposable.Instance; + } + + var __commandObs_s0 = (global::System.IObservable)new global::ReactiveUI.Binding.Observables.PropertyObservable( + target, + "Child", + (global::System.ComponentModel.INotifyPropertyChanged __o) => ((global::SharedScenarios.InvokeCommand.DeepCommandPath.MyViewModel)__o).Child, + false); + + var __commandObs_s1 = new global::ReactiveUI.Primitives.Advanced.SwitchMapSignal(__commandObs_s0, + __p1 => __p1 != null + ? global::ReactiveUI.Binding.Observables.PluginObservationSource.Choose( + __p1, + ((global::System.Linq.Expressions.Expression>)(__e => __e.Save)).Body, + "Save", + false, + 5, + (object __o) => ((global::SharedScenarios.InvokeCommand.DeepCommandPath.ChildViewModel)__o).Save, + new global::ReactiveUI.Binding.Observables.PropertyObservable( + (global::System.ComponentModel.INotifyPropertyChanged)__p1, + "Save", + (global::System.ComponentModel.INotifyPropertyChanged __o) => ((global::SharedScenarios.InvokeCommand.DeepCommandPath.ChildViewModel)__o).Save, + false)) + : (global::System.IObservable)new global::ReactiveUI.Primitives.Advanced.ImmediateReturnSignal(default(global::System.Windows.Input.ICommand))); + var commandObs = global::ReactiveUI.Primitives.LinqExtensions.DistinctUntilChanged(__commandObs_s1); + return global::ReactiveUI.Binding.CommandBinding.CommandInvoker.Invoke(source, commandObs); + } + + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/InterceptedCallSiteTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/InterceptedCallSiteTests.cs index 19c718af..8300fdae 100644 --- a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/InterceptedCallSiteTests.cs +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/InterceptedCallSiteTests.cs @@ -182,6 +182,7 @@ public void Bind(Person person, PersonView view, IObservable names) view.OneWayBind(person, x => x.Name, v => v.Summary); view.Bind(person, x => x.Name, v => v.Display); names.BindTo(view, v => v.Display); + names.InvokeCommand(person, x => x.Save); view.BindCommand(person, x => x.Save, v => v.SaveButton); view.BindCommand(person, x => x.Save, v => v.SaveButton, names); view.BindInteraction(person, x => x.Confirm, Handle); @@ -215,6 +216,7 @@ private static Task Handle(IInteractionContext context) "BindToDispatch.g.cs", "BindCommandDispatch.g.cs", "BindInteractionDispatch.g.cs", + "InvokeCommandDispatch.g.cs", ]; /// Every generated API is claimed the same way, so each emitter's tier follows one decision. diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/InvokeCommandGeneratorTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/InvokeCommandGeneratorTests.cs new file mode 100644 index 00000000..214d2b45 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/InvokeCommandGeneratorTests.cs @@ -0,0 +1,259 @@ +// 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; + +/// Snapshot tests for InvokeCommand (stream-driven command execution) invocation generation. +public class InvokeCommandGeneratorTests +{ + /// The InvokeCommandDispatch.g.cs name these tests generate against. + private const string InvokeCommandDispatchgcsName = "InvokeCommandDispatch.g.cs"; + + /// Verifies InvokeCommand observing a command property on the target. + /// A task representing the asynchronous test operation. + [Test] + public async Task CommandProperty() + { + var source = SharedSourceReader.ReadScenario("InvokeCommand/CommandProperty"); + var result = await TestHelper.TestPassWithResult( + source, + typeof(InvokeCommandGeneratorTests), + LanguageVersion.CSharp10); + + await result.CompilationSucceeds(); + await result.HasNoGeneratorDiagnostics(); + await result.HasGeneratedSource(InvokeCommandDispatchgcsName); + } + + /// Verifies InvokeCommand reaching the command through a property chain. + /// A task representing the asynchronous test operation. + [Test] + public async Task DeepCommandPath() + { + var source = SharedSourceReader.ReadScenario("InvokeCommand/DeepCommandPath"); + var result = await TestHelper.TestPassWithResult( + source, + typeof(InvokeCommandGeneratorTests), + LanguageVersion.CSharp10); + + await result.CompilationSucceeds(); + await result.HasNoGeneratorDiagnostics(); + } + + /// Verifies InvokeCommand dispatches on file and line when the consumer predates C# 10. + /// A task representing the asynchronous test operation. + [Test] + public async Task CommandProperty_CallerFilePath() + { + var source = SharedSourceReader.ReadScenario("InvokeCommand/CommandProperty"); + var result = await TestHelper.TestPassWithResult( + source, + typeof(InvokeCommandGeneratorTests), + LanguageVersion.CSharp7_3); + + await result.HasNoGeneratorDiagnostics(); + } + + /// A call to somebody else's InvokeCommand is not one of ours to generate for. + /// A task representing the asynchronous test operation. + [Test] + public async Task CustomExtension_GeneratesNoDispatch() + { + const string source = """ + using System; + using System.ComponentModel; + using System.Linq.Expressions; + using System.Windows.Input; + + namespace TestApp + { + public class MyViewModel : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + + public ICommand? Save { get; set; } + } + + public static class CustomExtensions + { + public static IDisposable InvokeCommand( + this IObservable source, + TTarget target, + Expression> commandProperty) => throw new NotImplementedException(); + } + + public static class Scenario + { + public static IDisposable Execute(IObservable values, MyViewModel vm) + { + return values.InvokeCommand(vm, x => x.Save); + } + } + } + """; + + var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10); + + await result.HasNoGeneratorDiagnostics(); + await result.DoesNotHaveGeneratedSource(InvokeCommandDispatchgcsName); + } + + /// A receiver that is no stream of values has nothing to drive an execution. + /// A task representing the asynchronous test operation. + [Test] + public async Task NonObservableReceiver_GeneratesNoDispatch() + { + const string source = """ + using System; + using System.ComponentModel; + + namespace TestApp + { + public class MyViewModel : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + + public string Caption { get; set; } = ""; + } + + public static class ReactiveUIBindingExtensions + { + public static IDisposable InvokeCommand(this MyViewModel source, object target, object commandProperty) => null!; + } + + public static class Scenario + { + public static void Execute(MyViewModel vm) + { + vm.InvokeCommand(vm, vm); + } + } + } + """; + + var result = TestHelper.RunGenerator(source); + + await result.HasNoGeneratorDiagnostics(); + await result.DoesNotHaveGeneratedSource(InvokeCommandDispatchgcsName); + } + + /// A selector held in a variable names no path to read, so nothing is generated for it. + /// A task representing the asynchronous test operation. + [Test] + public async Task SelectorFromAVariable_GeneratesNoDispatch() + { + const string source = """ + using System; + using System.ComponentModel; + using System.Linq.Expressions; + using System.Windows.Input; + using ReactiveUI.Binding; + + namespace TestApp + { + public class MyViewModel : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + + public ICommand? Save { get; set; } + } + + public static class Scenario + { + public static IDisposable Execute(IObservable values, MyViewModel vm) + { + Expression> selector = x => x.Save; + return values.InvokeCommand(vm, selector); + } + } + } + """; + + var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10); + + await result.HasNoGeneratorDiagnostics(); + await result.DoesNotHaveGeneratedSource(InvokeCommandDispatchgcsName); + } + + /// Two call sites spelling the same selector share one generated worker. + /// A task representing the asynchronous test operation. + [Test] + public async Task TwoCallSitesSharingASelector_ShareOneWorker() + { + const string source = """ + using System; + using System.ComponentModel; + using System.Windows.Input; + using ReactiveUI.Binding; + + namespace TestApp + { + public class MyViewModel : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + + public ICommand? Save { get; set; } + } + + public static class Scenario + { + public static IDisposable First(IObservable values, MyViewModel vm) + { + return values.InvokeCommand(vm, x => x.Save); + } + + public static IDisposable Second(IObservable values, MyViewModel vm) + { + return values.InvokeCommand(vm, x => x.Save); + } + } + } + """; + + var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10); + + await result.HasNoGeneratorDiagnostics(); + await result.CompilationSucceeds(); + + var dispatch = result.GeneratedSources[InvokeCommandDispatchgcsName]; + var workers = dispatch.Split("private static global::System.IDisposable __InvokeCommand_").Length - 1; + + await Assert.That(workers).IsEqualTo(1); + } + + /// + /// The overload taking the command itself has no property to observe, so it is served by the runtime library + /// and no dispatch is generated for it. + /// + /// A task representing the asynchronous test operation. + [Test] + public async Task CommandArgument_GeneratesNoDispatch() + { + const string source = """ + using System; + using System.Windows.Input; + using ReactiveUI.Binding; + + namespace TestApp + { + public static class Scenario + { + public static IDisposable Execute(IObservable values, ICommand command) + { + return values.InvokeCommand(command); + } + } + } + """; + + var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10); + + await result.HasNoGeneratorDiagnostics(); + await result.CompilationSucceeds(); + await result.DoesNotHaveGeneratedSource(InvokeCommandDispatchgcsName); + } +} diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ReactiveUI.Binding.SourceGenerators.Tests.csproj b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ReactiveUI.Binding.SourceGenerators.Tests.csproj index 3f9a8480..699137ca 100644 --- a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ReactiveUI.Binding.SourceGenerators.Tests.csproj +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/ReactiveUI.Binding.SourceGenerators.Tests.csproj @@ -53,6 +53,7 @@ + diff --git a/src/tests/ReactiveUI.Binding.Tests/CommandBinding/CommandInvokerTests.cs b/src/tests/ReactiveUI.Binding.Tests/CommandBinding/CommandInvokerTests.cs new file mode 100644 index 00000000..cd7f9f08 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.Tests/CommandBinding/CommandInvokerTests.cs @@ -0,0 +1,259 @@ +// 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.Reactive.Subjects; +using System.Windows.Input; +using ReactiveUI.Binding.CommandBinding; +using ReactiveUI.Binding.Tests.TestModels; + +namespace ReactiveUI.Binding.Tests.CommandBinding; + +/// Tests for , which both generated and runtime InvokeCommand paths use. +public class CommandInvokerTests +{ + /// The first value a test offers. + private const string FirstValue = "first"; + + /// The value a test offers after the first. + private const string SecondValue = "second"; + + /// The number of executions expected after offering two accepted values. + private const int TwoExecutions = 2; + + /// Each value the sequence produces is offered to the command as its parameter. + /// A task representing the asynchronous test operation. + [Test] + public async Task Invoke_FixedCommand_ExecutesWithEachValue() + { + var command = new RecordingCommand(); + var values = new Subject(); + + using var invocation = CommandInvoker.Invoke(values, command); + + values.OnNext(FirstValue); + values.OnNext(SecondValue); + + await Assert.That(command.ExecuteCount).IsEqualTo(TwoExecutions); + await Assert.That(command.LastParameter).IsEqualTo(SecondValue); + } + + /// A value the command refuses is dropped, and a later accepted value still executes. + /// A task representing the asynchronous test operation. + [Test] + public async Task Invoke_FixedCommand_RefusedValueIsDropped() + { + var command = new RecordingCommand { CanExecuteResult = false }; + var values = new Subject(); + + using var invocation = CommandInvoker.Invoke(values, command); + + values.OnNext(FirstValue); + + await Assert.That(command.ExecuteCount).IsEqualTo(0); + await Assert.That(command.LastQuestionedParameter).IsEqualTo(FirstValue); + + command.CanExecuteResult = true; + values.OnNext(SecondValue); + + await Assert.That(command.ExecuteCount).IsEqualTo(1); + } + + /// Disposing stops the executions. + /// A task representing the asynchronous test operation. + [Test] + public async Task Invoke_FixedCommand_Disposed_StopsExecuting() + { + var command = new RecordingCommand(); + var values = new Subject(); + + CommandInvoker.Invoke(values, command).Dispose(); + + values.OnNext(FirstValue); + + await Assert.That(command.ExecuteCount).IsEqualTo(0); + } + + /// The command the sequence of commands last produced is the one executed. + /// A task representing the asynchronous test operation. + [Test] + public async Task Invoke_ObservedCommand_ExecutesTheLatestCommand() + { + var first = new RecordingCommand(); + var second = new RecordingCommand(); + var commands = new Subject(); + var values = new Subject(); + + using var invocation = CommandInvoker.Invoke(values, commands); + + commands.OnNext(first); + values.OnNext(FirstValue); + commands.OnNext(second); + values.OnNext(SecondValue); + + await Assert.That(first.ExecuteCount).IsEqualTo(1); + await Assert.That(second.ExecuteCount).IsEqualTo(1); + await Assert.That(second.LastParameter).IsEqualTo(SecondValue); + } + + /// A command the sequence has not produced yet drops the values offered meanwhile. + /// A task representing the asynchronous test operation. + [Test] + public async Task Invoke_ObservedCommand_NoCommandYet_DropsTheValues() + { + var command = new RecordingCommand(); + var commands = new Subject(); + var values = new Subject(); + + using var invocation = CommandInvoker.Invoke(values, commands); + + values.OnNext(FirstValue); + commands.OnNext(command); + values.OnNext(SecondValue); + + await Assert.That(command.ExecuteCount).IsEqualTo(1); + await Assert.That(command.LastParameter).IsEqualTo(SecondValue); + } + + /// A null command drops the values offered while it stands. + /// A task representing the asynchronous test operation. + [Test] + public async Task Invoke_ObservedCommand_NullCommand_DropsTheValues() + { + var command = new RecordingCommand(); + var commands = new Subject(); + var values = new Subject(); + + using var invocation = CommandInvoker.Invoke(values, commands); + + commands.OnNext(command); + commands.OnNext(null); + values.OnNext(FirstValue); + + await Assert.That(command.ExecuteCount).IsEqualTo(0); + } + + /// Replacing the command does not itself execute anything. + /// A task representing the asynchronous test operation. + [Test] + public async Task Invoke_ObservedCommand_NewCommandAlone_DoesNotExecute() + { + var command = new RecordingCommand(); + var commands = new Subject(); + var values = new Subject(); + + using var invocation = CommandInvoker.Invoke(values, commands); + + values.OnNext(FirstValue); + commands.OnNext(command); + + await Assert.That(command.ExecuteCount).IsEqualTo(0); + } + + /// Disposing stops observing the commands as well as the values. + /// A task representing the asynchronous test operation. + [Test] + public async Task Invoke_ObservedCommand_Disposed_StopsExecuting() + { + var command = new RecordingCommand(); + var commands = new Subject(); + var values = new Subject(); + + CommandInvoker.Invoke(values, commands).Dispose(); + + commands.OnNext(command); + values.OnNext(FirstValue); + + await Assert.That(command.ExecuteCount).IsEqualTo(0); + await Assert.That(commands.HasObservers).IsFalse(); + } + + /// A fault in the values is surfaced rather than swallowed. + /// A task representing the asynchronous test operation. + [Test] + public async Task Invoke_ValuesFault_SurfacesTheFault() + { + var command = new RecordingCommand(); + var values = new ManualObservable(); + + using var invocation = CommandInvoker.Invoke(values, command); + + await Assert.That(() => values.Observer!.OnError(new InvalidOperationException("faulted"))) + .Throws(); + } + + /// A fault in the observed commands is surfaced rather than swallowed. + /// A task representing the asynchronous test operation. + [Test] + public async Task Invoke_ObservedCommandFaults_SurfacesTheFault() + { + var commands = new ManualObservable(); + var values = new Subject(); + + using var invocation = CommandInvoker.Invoke(values, commands); + + await Assert.That(() => commands.Observer!.OnError(new InvalidOperationException("faulted"))) + .Throws(); + } + + /// A completed sequence of values leaves a fixed command untouched rather than faulting. + /// A task representing the asynchronous test operation. + [Test] + public async Task Invoke_FixedCommand_ValuesComplete_DoesNotThrow() + { + var command = new RecordingCommand(); + var values = new ManualObservable(); + + using var invocation = CommandInvoker.Invoke(values, command); + + values.Observer!.OnCompleted(); + + await Assert.That(command.ExecuteCount).IsEqualTo(0); + } + + /// A completed sequence leaves the subscription in place rather than faulting. + /// A task representing the asynchronous test operation. + [Test] + public async Task Invoke_SequencesComplete_DoesNotThrow() + { + var command = new RecordingCommand(); + var commands = new ManualObservable(); + var values = new ManualObservable(); + + using var invocation = CommandInvoker.Invoke(values, commands); + + commands.Observer!.OnNext(command); + commands.Observer!.OnCompleted(); + values.Observer!.OnCompleted(); + + await Assert.That(command.ExecuteCount).IsEqualTo(0); + } + + /// A null sequence is rejected. + /// A task representing the asynchronous test operation. + [Test] + public async Task Invoke_NullSource_Throws() => + await Assert.That(static () => CommandInvoker.Invoke(null!, new RecordingCommand())) + .Throws(); + + /// A null command is rejected. + /// A task representing the asynchronous test operation. + [Test] + public async Task Invoke_NullCommand_Throws() => + await Assert.That(static () => CommandInvoker.Invoke(new Subject(), (ICommand)null!)) + .Throws(); + + /// A null sequence of commands is rejected. + /// A task representing the asynchronous test operation. + [Test] + public async Task Invoke_NullCommandSequence_Throws() => + await Assert.That(static () => CommandInvoker.Invoke(new Subject(), (IObservable)null!)) + .Throws(); + + /// A null sequence is rejected by the observed-command overload too. + /// A task representing the asynchronous test operation. + [Test] + public async Task Invoke_ObservedCommand_NullSource_Throws() => + await Assert.That(static () => CommandInvoker.Invoke(null!, new Subject())) + .Throws(); +} diff --git a/src/tests/ReactiveUI.Binding.Tests/Mixins/InvokeCommandTests.cs b/src/tests/ReactiveUI.Binding.Tests/Mixins/InvokeCommandTests.cs new file mode 100644 index 00000000..2a7fc98a --- /dev/null +++ b/src/tests/ReactiveUI.Binding.Tests/Mixins/InvokeCommandTests.cs @@ -0,0 +1,102 @@ +// 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.Reactive.Subjects; +using System.Windows.Input; +using ReactiveUI.Binding.Tests.Fallback; +using ReactiveUI.Binding.Tests.TestModels; + +namespace ReactiveUI.Binding.Tests.Mixins; + +/// +/// Tests the InvokeCommand surface the runtime library serves: the overload taking the command itself, +/// which has nothing to generate, and the overload naming a property, which reaches the runtime engine when no +/// generated dispatch claimed the call site. +/// +public class InvokeCommandTests +{ + /// The first value a test offers. + private const string FirstValue = "first"; + + /// The value a test offers after the first. + private const string SecondValue = "second"; + + /// The number of executions expected after offering two accepted values. + private const int TwoExecutions = 2; + + /// The overload taking the command executes it with each value. + /// A task representing the asynchronous test operation. + [Test] + public async Task InvokeCommand_GivenTheCommand_ExecutesWithEachValue() + { + var command = new RecordingCommand(); + var values = new Subject(); + + using var invocation = values.InvokeCommand(command); + + values.OnNext(FirstValue); + values.OnNext(SecondValue); + + await Assert.That(command.ExecuteCount).IsEqualTo(TwoExecutions); + await Assert.That(command.LastParameter).IsEqualTo(SecondValue); + } + + /// The overload taking the command rejects a null command rather than dropping every value. + /// A task representing the asynchronous test operation. + [Test] + public async Task InvokeCommand_GivenNoCommand_Throws() => + await Assert.That(static () => new Subject().InvokeCommand((ICommand)null!)) + .Throws(); + + /// The overload naming a property executes the command that property holds. + /// A task representing the asynchronous test operation. + [Test] + public async Task InvokeCommand_NamingAProperty_ExecutesThroughTheRuntimeEngine() + { + RuntimeObservationFallbackTests.EnsureInitialized(); + + var command = new RecordingCommand(); + var viewModel = new DispatchStubViewModel { Run = command }; + var values = new Subject(); + + using var invocation = values.InvokeCommand(viewModel, x => x.Run); + + values.OnNext(FirstValue); + + await Assert.That(command.ExecuteCount).IsEqualTo(1); + await Assert.That(command.LastParameter).IsEqualTo(FirstValue); + } + + /// An absent target holds no property to observe, so the values are dropped. + /// A task representing the asynchronous test operation. + [Test] + public async Task InvokeCommand_NamingAPropertyOnNothing_DropsTheValues() + { + RuntimeObservationFallbackTests.EnsureInitialized(); + + var values = new Subject(); + + using var invocation = values.InvokeCommand((DispatchStubViewModel?)null, x => x.Run); + + values.OnNext(FirstValue); + + await Assert.That(values.HasObservers).IsFalse(); + } + + /// A null selector is rejected. + /// A task representing the asynchronous test operation. + [Test] + public async Task InvokeCommand_NullSelector_Throws() => + await Assert.That(static () => + new Subject().InvokeCommand(new DispatchStubViewModel(), null!)) + .Throws(); + + /// A null sequence is rejected rather than deferred to the first value. + /// A task representing the asynchronous test operation. + [Test] + public async Task InvokeCommand_NullSource_Throws() => + await Assert.That(static () => + ((IObservable)null!).InvokeCommand(new DispatchStubViewModel(), x => x.Run)) + .Throws(); +} diff --git a/src/tests/ReactiveUI.Binding.Tests/TestModels/RecordingCommand.cs b/src/tests/ReactiveUI.Binding.Tests/TestModels/RecordingCommand.cs new file mode 100644 index 00000000..fe70c586 --- /dev/null +++ b/src/tests/ReactiveUI.Binding.Tests/TestModels/RecordingCommand.cs @@ -0,0 +1,44 @@ +// 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.Windows.Input; + +namespace ReactiveUI.Binding.Tests.TestModels; + +/// A command that records what it was offered and whether it accepted it. +public sealed class RecordingCommand : ICommand +{ + /// + public event EventHandler? CanExecuteChanged + { + add { /* CanExecute is asked per emission rather than tracked from the event. */ } + remove { /* CanExecute is asked per emission rather than tracked from the event. */ } + } + + /// Gets or sets a value indicating whether this command accepts what it is offered. + public bool CanExecuteResult { get; set; } = true; + + /// Gets the number of times this command was executed. + public int ExecuteCount { get; private set; } + + /// Gets the parameter of the most recent execution. + public object? LastParameter { get; private set; } + + /// Gets the parameter of the most recent question. + public object? LastQuestionedParameter { get; private set; } + + /// + public bool CanExecute(object? parameter) + { + LastQuestionedParameter = parameter; + return CanExecuteResult; + } + + /// + public void Execute(object? parameter) + { + ExecuteCount++; + LastParameter = parameter; + } +} diff --git a/src/tests/SharedScenarios/InvokeCommand/CommandProperty/MyViewModel.cs b/src/tests/SharedScenarios/InvokeCommand/CommandProperty/MyViewModel.cs new file mode 100644 index 00000000..25f40674 --- /dev/null +++ b/src/tests/SharedScenarios/InvokeCommand/CommandProperty/MyViewModel.cs @@ -0,0 +1,34 @@ +// 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.ComponentModel; +using System.Windows.Input; + +namespace SharedScenarios.InvokeCommand.CommandProperty; + +/// A view model holding the command to execute. +public class MyViewModel : INotifyPropertyChanged +{ + /// The backing field for . + private ICommand? _save; + + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// Gets or sets the command to execute. + public ICommand? Save + { + get => _save; + set + { + if (_save == value) + { + return; + } + + _save = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Save))); + } + } +} diff --git a/src/tests/SharedScenarios/InvokeCommand/CommandProperty/Scenario.cs b/src/tests/SharedScenarios/InvokeCommand/CommandProperty/Scenario.cs new file mode 100644 index 00000000..a94f2b5c --- /dev/null +++ b/src/tests/SharedScenarios/InvokeCommand/CommandProperty/Scenario.cs @@ -0,0 +1,21 @@ +// 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; +using System.Runtime.CompilerServices; +using ReactiveUI.Binding; + +namespace SharedScenarios.InvokeCommand.CommandProperty; + +/// Exercises InvokeCommand executing the command a view model property holds. +public static class Scenario +{ + /// Offers each value the stream produces to the view model's command. + /// The values driving the executions. + /// The view model holding the command. + /// A disposable that stops executing the command. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IDisposable Execute(IObservable values, MyViewModel viewModel) => + values.InvokeCommand(viewModel, x => x.Save); +} diff --git a/src/tests/SharedScenarios/InvokeCommand/DeepCommandPath/ChildViewModel.cs b/src/tests/SharedScenarios/InvokeCommand/DeepCommandPath/ChildViewModel.cs new file mode 100644 index 00000000..54a1f29c --- /dev/null +++ b/src/tests/SharedScenarios/InvokeCommand/DeepCommandPath/ChildViewModel.cs @@ -0,0 +1,34 @@ +// 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.ComponentModel; +using System.Windows.Input; + +namespace SharedScenarios.InvokeCommand.DeepCommandPath; + +/// The child holding the command to execute. +public class ChildViewModel : INotifyPropertyChanged +{ + /// The backing field for . + private ICommand? _save; + + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// Gets or sets the command to execute. + public ICommand? Save + { + get => _save; + set + { + if (_save == value) + { + return; + } + + _save = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Save))); + } + } +} diff --git a/src/tests/SharedScenarios/InvokeCommand/DeepCommandPath/MyViewModel.cs b/src/tests/SharedScenarios/InvokeCommand/DeepCommandPath/MyViewModel.cs new file mode 100644 index 00000000..9ca965fb --- /dev/null +++ b/src/tests/SharedScenarios/InvokeCommand/DeepCommandPath/MyViewModel.cs @@ -0,0 +1,33 @@ +// 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.ComponentModel; + +namespace SharedScenarios.InvokeCommand.DeepCommandPath; + +/// A view model whose child holds the command to execute. +public class MyViewModel : INotifyPropertyChanged +{ + /// The backing field for . + private ChildViewModel _child = new ChildViewModel(); + + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// Gets or sets the child holding the command. + public ChildViewModel Child + { + get => _child; + set + { + if (_child == value) + { + return; + } + + _child = value; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Child))); + } + } +} diff --git a/src/tests/SharedScenarios/InvokeCommand/DeepCommandPath/Scenario.cs b/src/tests/SharedScenarios/InvokeCommand/DeepCommandPath/Scenario.cs new file mode 100644 index 00000000..784a6034 --- /dev/null +++ b/src/tests/SharedScenarios/InvokeCommand/DeepCommandPath/Scenario.cs @@ -0,0 +1,21 @@ +// 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; +using System.Runtime.CompilerServices; +using ReactiveUI.Binding; + +namespace SharedScenarios.InvokeCommand.DeepCommandPath; + +/// Exercises InvokeCommand reaching the command through a chain the path follows. +public static class Scenario +{ + /// Offers each value the stream produces to the command the child currently holds. + /// The values driving the executions. + /// The view model whose child holds the command. + /// A disposable that stops executing the command. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static IDisposable Execute(IObservable values, MyViewModel viewModel) => + values.InvokeCommand(viewModel, x => x.Child.Save); +} From 4d15f90e4477176faed2a50349b741561525a9a9 Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Thu, 10 Sep 2026 11:20:07 +1000 Subject: [PATCH 2/2] test(binding): cover the shapes InvokeCommand declines and the faults it surfaces - A call the model could not resolve, a target held in a type parameter, and a selector whose body is no property path each reach a guard that had no test. - A fault in the values is surfaced while a command is being observed, which is a different observer from the one a fixed command uses. --- .../InvokeCommandGeneratorTests.cs | 114 ++++++++++++++++++ .../CommandBinding/CommandInvokerTests.cs | 21 +++- 2 files changed, 133 insertions(+), 2 deletions(-) diff --git a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/InvokeCommandGeneratorTests.cs b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/InvokeCommandGeneratorTests.cs index 214d2b45..b5d32cf8 100644 --- a/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/InvokeCommandGeneratorTests.cs +++ b/src/tests/ReactiveUI.Binding.SourceGenerators.Tests/InvokeCommandGeneratorTests.cs @@ -102,6 +102,120 @@ public static IDisposable Execute(IObservable values, MyViewModel vm) await result.DoesNotHaveGeneratedSource(InvokeCommandDispatchgcsName); } + /// A call the model could not resolve names no method to read arguments against. + /// A task representing the asynchronous test operation. + [Test] + public async Task UnresolvedCall_GeneratesNoDispatch() + { + const string source = """ + using System; + using System.ComponentModel; + using System.Windows.Input; + using ReactiveUI.Binding; + + namespace TestApp + { + public class MyViewModel : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + + public ICommand? Save { get; set; } + } + + public static class Scenario + { + public static void Execute(MyViewModel vm) + { + undefinedValues.InvokeCommand(vm, x => x.Save); + } + } + } + """; + + var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10); + + await result.HasNoGeneratorDiagnostics(); + await result.DoesNotHaveGeneratedSource(InvokeCommandDispatchgcsName); + } + + /// + /// A target held in a type parameter is named by nothing a generated member could declare, so the call site + /// is declined rather than emitted with the parameter's own name in it. + /// + /// A task representing the asynchronous test operation. + [Test] + public async Task GenericTargetParameter_GeneratesNoDispatch() + { + const string source = """ + using System; + using System.ComponentModel; + using System.Windows.Input; + using ReactiveUI.Binding; + + namespace TestApp + { + public interface IHasCommand : INotifyPropertyChanged + { + ICommand? Save { get; } + } + + public static class Scenario + { + public static IDisposable Execute(IObservable values, TTarget target) + where TTarget : class, IHasCommand + { + return values.InvokeCommand(target, x => x.Save); + } + } + } + """; + + var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10); + + await result.HasNoGeneratorDiagnostics(); + await result.CompilationSucceeds(); + await result.DoesNotHaveGeneratedSource(InvokeCommandDispatchgcsName); + } + + /// A selector whose body is no property path leaves nothing to observe. + /// A task representing the asynchronous test operation. + [Test] + public async Task SelectorWithoutAPropertyPath_GeneratesNoDispatch() + { + const string source = """ + using System; + using System.ComponentModel; + using System.Windows.Input; + using ReactiveUI.Binding; + + namespace TestApp + { + public class MyViewModel : INotifyPropertyChanged + { + public event PropertyChangedEventHandler? PropertyChanged; + + public ICommand? Save { get; set; } + + public ICommand? Resolve() => Save; + } + + public static class Scenario + { + public static IDisposable Execute(IObservable values, MyViewModel vm) + { + return values.InvokeCommand(vm, x => x.Resolve()); + } + } + } + """; + + var result = TestHelper.RunGenerator(source, LanguageVersion.CSharp10); + + await result.HasNoGeneratorDiagnostics(); + await result.CompilationSucceeds(); + await result.DoesNotHaveGeneratedSource(InvokeCommandDispatchgcsName); + } + /// A receiver that is no stream of values has nothing to drive an execution. /// A task representing the asynchronous test operation. [Test] diff --git a/src/tests/ReactiveUI.Binding.Tests/CommandBinding/CommandInvokerTests.cs b/src/tests/ReactiveUI.Binding.Tests/CommandBinding/CommandInvokerTests.cs index cd7f9f08..f02a52bf 100644 --- a/src/tests/ReactiveUI.Binding.Tests/CommandBinding/CommandInvokerTests.cs +++ b/src/tests/ReactiveUI.Binding.Tests/CommandBinding/CommandInvokerTests.cs @@ -21,6 +21,9 @@ public class CommandInvokerTests /// The number of executions expected after offering two accepted values. private const int TwoExecutions = 2; + /// The message a faulted sequence carries. + private const string FaultMessage = "faulted"; + /// Each value the sequence produces is offered to the command as its parameter. /// A task representing the asynchronous test operation. [Test] @@ -178,7 +181,21 @@ public async Task Invoke_ValuesFault_SurfacesTheFault() using var invocation = CommandInvoker.Invoke(values, command); - await Assert.That(() => values.Observer!.OnError(new InvalidOperationException("faulted"))) + await Assert.That(() => values.Observer!.OnError(new InvalidOperationException(FaultMessage))) + .Throws(); + } + + /// A fault in the values is surfaced while a command is being observed, too. + /// A task representing the asynchronous test operation. + [Test] + public async Task Invoke_ObservedCommand_ValuesFault_SurfacesTheFault() + { + var commands = new Subject(); + var values = new ManualObservable(); + + using var invocation = CommandInvoker.Invoke(values, commands); + + await Assert.That(() => values.Observer!.OnError(new InvalidOperationException(FaultMessage))) .Throws(); } @@ -192,7 +209,7 @@ public async Task Invoke_ObservedCommandFaults_SurfacesTheFault() using var invocation = CommandInvoker.Invoke(values, commands); - await Assert.That(() => commands.Observer!.OnError(new InvalidOperationException("faulted"))) + await Assert.That(() => commands.Observer!.OnError(new InvalidOperationException(FaultMessage))) .Throws(); }