diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index cfaebaf077..b188f889dd 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -20,8 +20,8 @@ 3.1.32 - - + + @@ -30,11 +30,11 @@ - + - + @@ -74,14 +74,14 @@ - - + + - + diff --git a/src/ReactiveUI.Wpf/Binding/ValidationBindingWpf.cs b/src/ReactiveUI.Wpf/Binding/ValidationBindingWpf.cs index 9b893b4291..d9674fdaa0 100644 --- a/src/ReactiveUI.Wpf/Binding/ValidationBindingWpf.cs +++ b/src/ReactiveUI.Wpf/Binding/ValidationBindingWpf.cs @@ -3,6 +3,7 @@ // The .NET Foundation 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.Linq.Expressions; using System.Windows; using System.Windows.Data; @@ -223,6 +224,17 @@ internal static IEnumerable EnumerateAttachedProperties(obje return null; } + if (element is DependencyObject dependencyObject) + { + var targetType = dependencyObject.DependencyObjectType.SystemType; + var descriptor = DependencyPropertyDescriptor.FromName(name, targetType, targetType); + + if (descriptor?.DependencyProperty is not null) + { + return descriptor.DependencyProperty; + } + } + return EnumerateDependencyProperties(element) .Concat(EnumerateAttachedProperties(element)) .FirstOrDefault(x => x.Name == name); diff --git a/src/ReactiveUI/Interactions/Interaction.cs b/src/ReactiveUI/Interactions/Interaction.cs index 97a633f63b..f050ae24bf 100644 --- a/src/ReactiveUI/Interactions/Interaction.cs +++ b/src/ReactiveUI/Interactions/Interaction.cs @@ -87,11 +87,13 @@ public IDisposable RegisterHandler(Action> { ArgumentExceptionHelper.ThrowIfNull(handler); - return RegisterHandler(interaction => + IObservable ContentHandler(IInteractionContext interaction) { handler(interaction); return Observables.Unit; - }); + } + + return RegisterHandlerCore(ContentHandler); } /// @@ -99,7 +101,15 @@ public IDisposable RegisterHandler(Func, Ta { ArgumentExceptionHelper.ThrowIfNull(handler); - return RegisterHandler(interaction => handler(interaction).ToObservable()); + IObservable ContentHandler(IInteractionContext interaction) => + Observable.FromAsync( + async () => + { + await YieldToCurrentContext(); + await handler(interaction).ConfigureAwait(false); + }); + + return RegisterHandlerCore(ContentHandler); } /// @@ -107,10 +117,16 @@ public IDisposable RegisterHandler(Func ContentHandler(IInteractionContext context) => handler(context).Select(_ => Unit.Default); + IObservable ContentHandler(IInteractionContext context) => + Observable.FromAsync( + async () => + { + await YieldToCurrentContext(); + return handler(context); + }) + .SelectMany(result => result.Select(_ => Unit.Default)); - AddHandler(ContentHandler); - return Disposable.Create(() => RemoveHandler(ContentHandler)); + return RegisterHandlerCore(ContentHandler); } /// @@ -153,6 +169,27 @@ protected Func, IObservable>[] GetHan /// The interaction context. protected virtual IOutputContext GenerateContext(TInput input) => new InteractionContext(input); + /// + /// Yields once so asynchronous handlers are not invoked inside the current scheduler trampoline. + /// + /// A task that completes after the current context has yielded. + private static async Task YieldToCurrentContext() + { + await Task.Yield(); + } + + /// + /// Registers a normalized interaction handler. + /// + /// The normalized handler. + /// A disposable which unregisters the handler. + private IDisposable RegisterHandlerCore(Func, IObservable> contentHandler) + { + ArgumentExceptionHelper.ThrowIfNull(contentHandler); + AddHandler(contentHandler); + return Disposable.Create(() => RemoveHandler(contentHandler)); + } + /// /// Adds a handler delegate to be invoked for interaction contexts. /// diff --git a/src/ReactiveUI/Suspension/SuspensionHostExtensions.cs b/src/ReactiveUI/Suspension/SuspensionHostExtensions.cs index bf2ea63229..e58c7cab64 100644 --- a/src/ReactiveUI/Suspension/SuspensionHostExtensions.cs +++ b/src/ReactiveUI/Suspension/SuspensionHostExtensions.cs @@ -284,16 +284,20 @@ private static IObservable EnsureLoadAppState(this ISuspensionHost item, I return Observable.Return(Unit.Default); } + object? loadedState; + try { - item.AppState = _suspensionDriver.LoadState().Wait(); + loadedState = _suspensionDriver.LoadState().Wait(); } catch (Exception ex) { item.Log().Warn(ex, "Failed to restore app state from storage, creating from scratch"); - item.AppState = item.CreateNewAppState?.Invoke(); + loadedState = null; } + item.AppState = loadedState ?? item.CreateNewAppState?.Invoke(); + return Observable.Return(Unit.Default); } @@ -321,16 +325,20 @@ private static IObservable EnsureLoadAppState(this ISuspensionH return Observable.Return(Unit.Default); } + TAppState? loadedState; + try { - item.AppStateValue = _suspensionDriver.LoadState(typeInfo).Wait(); + loadedState = _suspensionDriver.LoadState(typeInfo).Wait(); } catch (Exception ex) { item.Log().Warn(ex, "Failed to restore app state from storage, creating from scratch"); - item.AppStateValue = item.CreateNewAppStateTyped?.Invoke(); + loadedState = null; } + item.AppStateValue = loadedState ?? item.CreateNewAppStateTyped?.Invoke(); + return Observable.Return(Unit.Default); } } diff --git a/src/tests/ReactiveUI.Test.Utilities/SuspensionHost/SuspensionHostTestExecutor.cs b/src/tests/ReactiveUI.Test.Utilities/SuspensionHost/SuspensionHostTestExecutor.cs index 15a7b61d64..34abf74919 100644 --- a/src/tests/ReactiveUI.Test.Utilities/SuspensionHost/SuspensionHostTestExecutor.cs +++ b/src/tests/ReactiveUI.Test.Utilities/SuspensionHost/SuspensionHostTestExecutor.cs @@ -5,6 +5,8 @@ using System.Reactive; +using ReactiveUI.Tests.Utilities.AppBuilder; + namespace ReactiveUI.Tests.Utilities.SuspensionHost; /// @@ -17,13 +19,13 @@ namespace ReactiveUI.Tests.Utilities.SuspensionHost; /// - SuspensionHostExtensions.SuspensionDriver /// Tests using this executor should be marked with [NotInParallel] due to static state modifications. /// -public class SuspensionHostTestExecutor : ITestExecutor +public class SuspensionHostTestExecutor : AppBuilderTestExecutor { private Func>? _previousEnsureLoadAppStateFunc; private ISuspensionDriver? _previousSuspensionDriver; /// - public virtual async ValueTask ExecuteTest(TestContext context, Func testAction) + public override async ValueTask ExecuteTest(TestContext context, Func testAction) { ArgumentNullException.ThrowIfNull(testAction); @@ -31,7 +33,7 @@ public virtual async ValueTask ExecuteTest(TestContext context, Func try { - await testAction(); + await base.ExecuteTest(context, testAction); } finally { diff --git a/src/tests/ReactiveUI.Tests/InteractionsTest.cs b/src/tests/ReactiveUI.Tests/InteractionsTest.cs index 726f8117b5..082773fe66 100644 --- a/src/tests/ReactiveUI.Tests/InteractionsTest.cs +++ b/src/tests/ReactiveUI.Tests/InteractionsTest.cs @@ -10,6 +10,7 @@ namespace ReactiveUI.Tests; /// /// Tests interactions. /// +[NotInParallel] public class InteractionsTest { /// @@ -23,8 +24,8 @@ public async Task AttemptingToGetInteractionOutputBeforeItHasBeenSetShouldCauseE interaction.RegisterHandler(context => { _ = ((InteractionContext)context).GetOutput(); }); - var ex = Assert.Throws(() => interaction.Handle(Unit.Default).Subscribe()); - await Assert.That(ex.Message).IsEqualTo("Output has not been set."); + var ex = await Assert.ThrowsAsync(() => interaction.Handle(Unit.Default).ToTask()); + await Assert.That(ex!.Message).IsEqualTo("Output has not been set."); } /// @@ -42,20 +43,21 @@ public async Task AttemptingToSetInteractionOutputMoreThanOnceShouldCauseExcepti context.SetOutput(Unit.Default); }); - var ex = Assert.Throws(() => interaction.Handle(Unit.Default).Subscribe()); - await Assert.That(ex.Message).IsEqualTo("Output has already been set."); + var ex = await Assert.ThrowsAsync(() => interaction.Handle(Unit.Default).ToTask()); + await Assert.That(ex!.Message).IsEqualTo("Output has already been set."); } /// /// Tests that Handled interactions should not cause exception. /// + /// A representing the asynchronous operation. [Test] - public void HandledInteractionsShouldNotCauseException() + public async Task HandledInteractionsShouldNotCauseException() { var interaction = new Interaction(); interaction.RegisterHandler(static c => c.SetOutput(true)); - interaction.Handle(Unit.Default).FirstAsync().Wait(); + await interaction.Handle(Unit.Default); } /// @@ -63,21 +65,32 @@ public void HandledInteractionsShouldNotCauseException() /// /// A representing the asynchronous operation. [Test] - [TestExecutor] public async Task HandlersAreExecutedOnHandlerScheduler() { - var scheduler = TestContext.Current!.GetScheduler(); + var schedulerThreadId = -1; + using var scheduler = new EventLoopScheduler( + threadStart => + { + var thread = new Thread(threadStart) { IsBackground = true }; + schedulerThreadId = thread.ManagedThreadId; + return thread; + }); var interaction = new Interaction(scheduler); + var handlerThreadId = -1; - using (interaction.RegisterHandler(x => x.SetOutput("done"))) + using (interaction.RegisterHandler(x => { - var handled = false; - interaction - .Handle(Unit.Default) - .Subscribe(_ => handled = true); + handlerThreadId = Environment.CurrentManagedThreadId; + x.SetOutput("done"); + })) + { + var result = await interaction.Handle(Unit.Default).ToTask().WaitAsync(TimeSpan.FromSeconds(5)); - // With ImmediateScheduler, handlers execute immediately - await Assert.That(handled).IsTrue(); + using (Assert.Multiple()) + { + await Assert.That(result).IsEqualTo("done"); + await Assert.That(handlerThreadId).IsEqualTo(schedulerThreadId); + } } } @@ -95,16 +108,27 @@ public async Task HandlersCanContainAsynchronousCode() // even though handler B is "slow" (i.e. mimicks waiting for the user), it takes precedence over A, so we expect A to never even be called var handler1AWasCalled = false; + var handler1BWasSubscribed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var handler1A = interaction.RegisterHandler(x => { x.SetOutput("A"); handler1AWasCalled = true; }); var handler1B = interaction.RegisterHandler(x => - Observables - .Unit - .Delay(TimeSpan.FromSeconds(1), scheduler) - .Do(_ => x.SetOutput("B"))); + { + return Observable.Create( + observer => + { + var subscription = Observables + .Unit + .Delay(TimeSpan.FromSeconds(1), scheduler) + .Do(_ => x.SetOutput("B")) + .Subscribe(observer); + + handler1BWasSubscribed.TrySetResult(); + return subscription; + }); + }); using (handler1A) using (handler1B) @@ -113,6 +137,7 @@ public async Task HandlersCanContainAsynchronousCode() .Handle(Unit.Default) .ToObservableChangeSet(ImmediateScheduler.Instance).Bind(out var result).Subscribe(); + await handler1BWasSubscribed.Task.WaitAsync(TimeSpan.FromSeconds(5)); await Assert.That(result).IsEmpty(); scheduler.AdvanceBy(TimeSpan.FromSeconds(0.5)); await Assert.That(result).IsEmpty(); @@ -139,14 +164,67 @@ public async Task HandlersCanContainAsynchronousCodeViaTasks() return Task.FromResult(true); }); - string? result = null; - interaction - .Handle(Unit.Default) - .Subscribe(r => result = r); + var result = await interaction.Handle(Unit.Default); await Assert.That(result).IsEqualTo("result"); } + /// + /// Tests that task handlers release the current scheduler before invoking user code. + /// + /// A representing the asynchronous operation. + [Test] + public async Task TaskHandlersShouldNotBlockNestedInteractionsBeforeReturningTask() + { + var parent = new Interaction(); + var nested = new Interaction(); + var nestedHandledBeforeParentReturned = false; + string? nestedOutput = null; + + nested.RegisterHandler(context => context.SetOutput("nested")); + + parent.RegisterHandler(context => + { + using var nestedSubscription = nested.Handle(Unit.Default).Subscribe(output => nestedOutput = output); + nestedHandledBeforeParentReturned = nestedOutput == "nested"; + + context.SetOutput(Unit.Default); + return Task.CompletedTask; + }); + + await parent.Handle(Unit.Default); + + await Assert.That(nestedHandledBeforeParentReturned).IsTrue(); + } + + /// + /// Tests that observable handlers release the current scheduler before invoking user code. + /// + /// A representing the asynchronous operation. + [Test] + public async Task ObservableHandlersShouldNotBlockNestedInteractionsBeforeReturningObservable() + { + var parent = new Interaction(); + var nested = new Interaction(); + var nestedHandledBeforeParentReturned = false; + string? nestedOutput = null; + + nested.RegisterHandler(context => context.SetOutput("nested")); + + parent.RegisterHandler(context => + { + using var nestedSubscription = nested.Handle(Unit.Default).Subscribe(output => nestedOutput = output); + nestedHandledBeforeParentReturned = nestedOutput == "nested"; + + context.SetOutput(Unit.Default); + return Observables.Unit; + }); + + await parent.Handle(Unit.Default); + + await Assert.That(nestedHandledBeforeParentReturned).IsTrue(); + } + /// /// Tests that handlers can opt not to handle the interaction. /// @@ -174,21 +252,21 @@ public async Task HandlersCanOptNotToHandleTheInteraction() using (handler1C) using (Assert.Multiple()) { - await Assert.That(interaction.Handle(false).FirstAsync().Wait()).IsEqualTo("C"); - await Assert.That(interaction.Handle(true).FirstAsync().Wait()).IsEqualTo("C"); + await Assert.That(await interaction.Handle(false)).IsEqualTo("C"); + await Assert.That(await interaction.Handle(true)).IsEqualTo("C"); } using (Assert.Multiple()) { - await Assert.That(interaction.Handle(false).FirstAsync().Wait()).IsEqualTo("A"); - await Assert.That(interaction.Handle(true).FirstAsync().Wait()).IsEqualTo("B"); + await Assert.That(await interaction.Handle(false)).IsEqualTo("A"); + await Assert.That(await interaction.Handle(true)).IsEqualTo("B"); } } using (Assert.Multiple()) { - await Assert.That(interaction.Handle(false).FirstAsync().Wait()).IsEqualTo("A"); - await Assert.That(interaction.Handle(true).FirstAsync().Wait()).IsEqualTo("A"); + await Assert.That(await interaction.Handle(false)).IsEqualTo("A"); + await Assert.That(await interaction.Handle(true)).IsEqualTo("A"); } } } @@ -207,7 +285,7 @@ public async Task HandlersReturningObservablesCanReturnAnyKindOfObservable() .Return(42) .Do(_ => x.SetOutput("result"))); - var result = interaction.Handle(Unit.Default).FirstAsync().Wait(); + var result = await interaction.Handle(Unit.Default); await Assert.That(result).IsEqualTo("result"); } @@ -222,19 +300,19 @@ public async Task NestedHandlersAreExecutedInReverseOrderOfSubscription() using (interaction.RegisterHandler(static x => x.SetOutput("A"))) { - await Assert.That(interaction.Handle(Unit.Default).FirstAsync().Wait()).IsEqualTo("A"); + await Assert.That(await interaction.Handle(Unit.Default)).IsEqualTo("A"); using (interaction.RegisterHandler(static x => x.SetOutput("B"))) { - await Assert.That(interaction.Handle(Unit.Default).FirstAsync().Wait()).IsEqualTo("B"); + await Assert.That(await interaction.Handle(Unit.Default)).IsEqualTo("B"); using (interaction.RegisterHandler(static x => x.SetOutput("C"))) { - await Assert.That(interaction.Handle(Unit.Default).FirstAsync().Wait()).IsEqualTo("C"); + await Assert.That(await interaction.Handle(Unit.Default)).IsEqualTo("C"); } - await Assert.That(interaction.Handle(Unit.Default).FirstAsync().Wait()).IsEqualTo("B"); + await Assert.That(await interaction.Handle(Unit.Default)).IsEqualTo("B"); } - await Assert.That(interaction.Handle(Unit.Default).FirstAsync().Wait()).IsEqualTo("A"); + await Assert.That(await interaction.Handle(Unit.Default)).IsEqualTo("A"); } } @@ -261,21 +339,21 @@ public void RegisterNullHandlerShouldCauseException() public async Task UnhandledInteractionsShouldCauseException() { var interaction = new Interaction(); - var ex = Assert.Throws>(() => - interaction.Handle("foo").FirstAsync().Wait()); + var ex = await Assert.ThrowsAsync>(() => + interaction.Handle("foo").ToTask()); using (Assert.Multiple()) { - await Assert.That(ex.Interaction).IsSameReferenceAs(interaction); + await Assert.That(ex!.Interaction).IsSameReferenceAs(interaction); await Assert.That(ex.Input).IsEqualTo("foo"); } interaction.RegisterHandler(_ => { }); interaction.RegisterHandler(_ => { }); - ex = Assert.Throws>(() => - interaction.Handle("bar").FirstAsync().Wait()); + ex = await Assert.ThrowsAsync>(() => + interaction.Handle("bar").ToTask()); using (Assert.Multiple()) { - await Assert.That(ex.Interaction).IsSameReferenceAs(interaction); + await Assert.That(ex!.Interaction).IsSameReferenceAs(interaction); await Assert.That(ex.Input).IsEqualTo("bar"); } } diff --git a/src/tests/ReactiveUI.Tests/Suspension/SuspensionHostExtensionsAotTests.cs b/src/tests/ReactiveUI.Tests/Suspension/SuspensionHostExtensionsAotTests.cs index 8728f0ce43..28810305ff 100644 --- a/src/tests/ReactiveUI.Tests/Suspension/SuspensionHostExtensionsAotTests.cs +++ b/src/tests/ReactiveUI.Tests/Suspension/SuspensionHostExtensionsAotTests.cs @@ -69,6 +69,36 @@ public async Task GetAppState_Typed_TriggersEnsureLoadAppState() await Assert.That(driver.LoadStateCallCount).IsEqualTo(1); } + [Test] + public async Task GetAppState_Typed_WhenNoPersistedState_CreatesAndStoresNewAppState() + { + var createdState = new TestAppState { Value = 99 }; + var createNewAppStateCallCount = 0; + using var host = new SuspensionHost + { + CreateNewAppStateTyped = () => + { + createNewAppStateCallCount++; + return createdState; + }, + IsLaunchingNew = Observable.Never(), + IsResuming = Observable.Never(), + ShouldPersistState = Observable.Never(), + ShouldInvalidateState = Observable.Never() + }; + + var driver = new TestSuspensionDriver(); + + using var disposable = host.SetupDefaultSuspendResume(TestAppStateContext.Default.TestAppState, driver); + + var state = host.GetAppState(); + + await Assert.That(state).IsSameReferenceAs(createdState); + await Assert.That(host.AppStateValue).IsSameReferenceAs(createdState); + await Assert.That(createNewAppStateCallCount).IsEqualTo(1); + await Assert.That(driver.LoadStateCallCount).IsEqualTo(1); + } + [Test] public async Task ObserveAppState_Typed_EmitsCurrentValueImmediately() { diff --git a/src/tests/ReactiveUI.Tests/SuspensionHostExtensionsTests.cs b/src/tests/ReactiveUI.Tests/SuspensionHostExtensionsTests.cs index 0d47e0bd1f..818d3d37d5 100644 --- a/src/tests/ReactiveUI.Tests/SuspensionHostExtensionsTests.cs +++ b/src/tests/ReactiveUI.Tests/SuspensionHostExtensionsTests.cs @@ -124,6 +124,36 @@ public async Task EnsureLoadAppState_LoadStateThrows_CreatesNewAppState() await Assert.That(driver.LoadStateCallCount).IsEqualTo(1); } + [Test] + public async Task GetAppState_WhenNoPersistedState_CreatesAndStoresNewAppState() + { + var createdState = new DummyAppState(); + var createNewAppStateCallCount = 0; + using var host = new SuspensionHost + { + CreateNewAppState = () => + { + createNewAppStateCallCount++; + return createdState; + }, + IsLaunchingNew = Observable.Never(), + IsResuming = Observable.Never(), + ShouldPersistState = Observable.Never(), + ShouldInvalidateState = Observable.Never() + }; + + var driver = new TestSuspensionDriver { ReturnNullOnLoad = true }; + + using var disposable = host.SetupDefaultSuspendResume(driver); + + var state = host.GetAppState(); + + await Assert.That(state).IsSameReferenceAs(createdState); + await Assert.That(host.AppState).IsSameReferenceAs(createdState); + await Assert.That(createNewAppStateCallCount).IsEqualTo(1); + await Assert.That(driver.LoadStateCallCount).IsEqualTo(1); + } + [Test] public async Task EnsureLoadAppState_WithExistingAppState_DoesNotLoad() { @@ -475,6 +505,8 @@ private class TestSuspensionDriver : ISuspensionDriver public bool ShouldThrowOnLoad { get; set; } + public bool ReturnNullOnLoad { get; set; } + public object? StateToLoad { get; set; } public IObservable InvalidateState() @@ -497,6 +529,11 @@ public IObservable InvalidateState() ImmediateScheduler.Instance); } + if (ReturnNullOnLoad) + { + return Observable.Return(null, ImmediateScheduler.Instance); + } + return Observable.Return(StateToLoad ?? new DummyAppState(), ImmediateScheduler.Instance); } diff --git a/src/tests/ReactiveUI.Wpf.Tests/Wpf/ValidationBindingWpfTest.cs b/src/tests/ReactiveUI.Wpf.Tests/Wpf/ValidationBindingWpfTest.cs index a9ce0bb7ac..349ebe02a2 100644 --- a/src/tests/ReactiveUI.Wpf.Tests/Wpf/ValidationBindingWpfTest.cs +++ b/src/tests/ReactiveUI.Wpf.Tests/Wpf/ValidationBindingWpfTest.cs @@ -5,6 +5,8 @@ using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; using ReactiveUI.Wpf.Binding; @@ -189,6 +191,36 @@ public async Task GetDependencyProperty_FindsPropertyByName() await Assert.That(result!.Name).IsEqualTo("Text"); } + /// + /// Tests that GetDependencyProperty finds dependency properties inherited from base controls. + /// + /// A representing the asynchronous operation. + [Test] + public async Task GetDependencyProperty_FindsInheritedPropertyByName() + { + var comboBox = new ComboBox(); + + var result = ValidationBindingWpf.GetDependencyProperty(comboBox, nameof(Selector.SelectedValue)); + + await Assert.That(result).IsNotNull(); + await Assert.That(result).IsSameReferenceAs(Selector.SelectedValueProperty); + } + + /// + /// Tests that GetDependencyProperty finds dependency properties inherited by TextBox. + /// + /// A representing the asynchronous operation. + [Test] + public async Task GetDependencyProperty_FindsInheritedTextBoxBasePropertyByName() + { + var textBox = new TextBox(); + + var result = ValidationBindingWpf.GetDependencyProperty(textBox, nameof(TextBoxBase.IsReadOnly)); + + await Assert.That(result).IsNotNull(); + await Assert.That(result).IsSameReferenceAs(TextBoxBase.IsReadOnlyProperty); + } + /// /// Tests that GetDependencyProperty returns null for non-existent property. /// @@ -375,6 +407,25 @@ public async Task Constructor_ThrowsWhenDependencyPropertyNotFound() } } + /// + /// Tests that BindWithValidation supports ComboBox.SelectedValue. + /// + /// A representing the asynchronous operation. + [Test] + public async Task BindWithValidation_BindsComboBoxSelectedValue() + { + var view = new TestViewWithComboBox(); + var viewModel = new TestViewModel { SelectedValue = "initial" }; + view.ViewModel = viewModel; + + using var binding = view.BindWithValidation(viewModel, vm => vm.SelectedValue, v => v.MyComboBox.SelectedValue); + + var bindingExpression = BindingOperations.GetBindingExpression(view.MyComboBox, Selector.SelectedValueProperty); + + await Assert.That(bindingExpression).IsNotNull(); + await Assert.That(bindingExpression!.ParentBinding.Path.Path).IsEqualTo("SelectedValue"); + } + /// /// Tests that Dispose clears the binding. /// @@ -645,10 +696,30 @@ public TestViewWithControl() public TextBox MyTextBox { get; } } + private class TestViewWithComboBox : StackPanel, IViewFor + { + public TestViewWithComboBox() + { + MyComboBox = new ComboBox { Name = "MyComboBox" }; + Children.Add(MyComboBox); + } + + public TestViewModel? ViewModel { get; set; } + + object? IViewFor.ViewModel + { + get => ViewModel; + set => ViewModel = value as TestViewModel; + } + + public ComboBox MyComboBox { get; } + } + private class TestViewModel : ReactiveObject { private string? _testProperty; private NestedTestObject? _nestedObject; + private string? _selectedValue; public string? TestProperty { @@ -661,6 +732,12 @@ public NestedTestObject? NestedObject get => _nestedObject; set => this.RaiseAndSetIfChanged(ref _nestedObject, value); } + + public string? SelectedValue + { + get => _selectedValue; + set => this.RaiseAndSetIfChanged(ref _selectedValue, value); + } } private class NestedTestObject