diff --git a/AGENTS.md b/AGENTS.md index f2a0e5d..8895c5f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,20 +8,19 @@ This document provides guidance for AI agents and contributors working on the ** MvvmControls is a WPF MVVM helper library that consolidates boiler-plate ViewModel interactions for common WPF controls into pre-built classes. It ships alongside a set of Roslyn source generators that reduce the amount of code a consumer needs to write. -The solution (`RFBCodeWorks.Mvvm.sln`) is composed of the following projects: - -| Project | Path | Status | +The solution (`RFBCodeWorks.Mvvm.sln`) is organized into the following logical components: +| Component | Path / Projects | Status | |---|---|---| | **Mvvm.Controls** | `src/Mvvm.Controls/` | Primary library – active development | -| **Mvvm.Controls.SourceGenerators** | `src/Mvvm.Controls.SourceGenerators/` | Source generators – active development | +| **Source generators** | `src/SourceGenerators.Roslyn311/`, `src/SourceGenerators.Roslyn410/`, `src/SourceGenerators.Roslyn500/` | Roslyn-version-specific generator projects – active development | | Mvvm.IViewModel | `src/Mvvm.IViewModel/` | Interface-only library – largely complete | | Mvvm.Dialogs | `src/Mvvm.Dialogs/` | Dialog helpers – largely complete | | Mvvm.WebView2Integration | `src/Mvvm.WebView2Integration/` | WebView2 helpers – largely complete | | ExampleWPF | `src/ExampleWPF/` | Demo application – reference only | -| MvvmControlsTests | `tests/MvvmControlsTests/` | Test project | - -> **Focus your work on `Mvvm.Controls` and `Mvvm.Controls.SourceGenerators`**. The other libraries are considered feature-complete and should not need significant changes. +| TestWebView2 | `src/TestWebView2/` | WebView2 sample/test application | +| Mvvm.Controls.Tests | `tests/Mvvm.Controls.Tests/` | Test project | +> **Focus your work on `Mvvm.Controls` and the `SourceGenerators.Roslyn311/410/500` projects**. The other libraries are considered feature-complete and should not need significant changes. --- ## Build & Test @@ -107,7 +106,7 @@ The source generator code is compiled three times against different Roslyn SDK v |---|---|---| | `SourceGenerators.Roslyn311.csproj` | 3.11 | VS2019, .NET Framework / older toolchains | | `SourceGenerators.Roslyn410.csproj` | 4.10 | VS2022 (current) | -| `SourceGenerators.Roslyn500.csproj` | 5.0 | VS2026 / .NET 10 toolchain | +| `SourceGenerators.Roslyn500.csproj` | 5.x | VS2026 / .NET 10 toolchain | All three projects share the same source files under `src/`. Version-specific files use the naming convention `*Roslyn311.cs` / `*Roslyn3*.cs` (excluded from Roslyn4+ builds). diff --git a/RFBCodeWorks.Mvvm.sln b/RFBCodeWorks.Mvvm.sln index 616a7c3..a7bb1ac 100644 --- a/RFBCodeWorks.Mvvm.sln +++ b/RFBCodeWorks.Mvvm.sln @@ -16,6 +16,7 @@ EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".Solution Items", ".Solution Items", "{F7F23A78-3D3B-40F2-9FC0-B4B70B15378A}" ProjectSection(SolutionItems) = preProject .editorconfig = .editorconfig + AGENTS.md = AGENTS.md .github\workflows\build-and-test.yml = .github\workflows\build-and-test.yml MvvmControls.snk = MvvmControls.snk README.md = README.md diff --git a/src/Mvvm.Controls/CSharp9_MissingComponents.cs b/src/Mvvm.Controls/CSharp9_MissingComponents.cs index 5be3a69..6a2b632 100644 --- a/src/Mvvm.Controls/CSharp9_MissingComponents.cs +++ b/src/Mvvm.Controls/CSharp9_MissingComponents.cs @@ -28,6 +28,12 @@ internal static class IsExternalInit namespace System.Diagnostics.CodeAnalysis { + [AttributeUsage(validOn: AttributeTargets.Parameter)] + internal class NotNullAttribute : Attribute { } + + [AttributeUsage(validOn: AttributeTargets.Method)] + internal class DoesNotReturnAttribute : Attribute { } + [AttributeUsage(AttributeTargets.Property | AttributeTargets.Method, AllowMultiple = true)] internal sealed class MemberNotNullWhenAttribute(bool returnValue, params string[] members) : Attribute { diff --git a/src/Mvvm.Controls/Mvvm/ExtensionMethods.cs b/src/Mvvm.Controls/Mvvm/ExtensionMethods.cs index 35ead07..fe1bf7d 100644 --- a/src/Mvvm.Controls/Mvvm/ExtensionMethods.cs +++ b/src/Mvvm.Controls/Mvvm/ExtensionMethods.cs @@ -2,6 +2,7 @@ using System.Collections; using System.Collections.Generic; using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; #nullable enable @@ -10,11 +11,18 @@ namespace RFBCodeWorks.Mvvm internal static class ExtensionMethods { - public static void ThrowIfNull(this T value, string paramName) + public static void ThrowIfNull([NotNull] this T value, string paramName) { if (value is null) ThrowArgNull(paramName); } - private static void ThrowArgNull(string? paramName) => throw new ArgumentNullException(paramName); + + public static void ThrowInvalidOperationIfNull([NotNull] this T value, string message) + { + if (value is null) ThrowInvalidOperation(message); + } + + [DoesNotReturn] private static void ThrowArgNull(string? paramName) => throw new ArgumentNullException(paramName); + [DoesNotReturn] private static void ThrowInvalidOperation(string message) => throw new InvalidOperationException(message); /// diff --git a/src/Mvvm.Controls/Mvvm/IRefreshableItemSource.cs b/src/Mvvm.Controls/Mvvm/IRefreshableItemSource.cs index be8fe55..db2a7cd 100644 --- a/src/Mvvm.Controls/Mvvm/IRefreshableItemSource.cs +++ b/src/Mvvm.Controls/Mvvm/IRefreshableItemSource.cs @@ -35,31 +35,51 @@ public interface IRefreshableItemSource : IItemSource /// /// A RelayCommand that can be used to refresh the collection /// - public IRelayCommand RefreshCommand { get; } + IRelayCommand RefreshCommand { get; } /// /// Gets a command that can be used to cancel the refresh if a cancellable refresh method was supplied to the constructor. /// - public System.Windows.Input.ICommand CancelRefreshCommand { get; } + System.Windows.Input.ICommand CancelRefreshCommand { get; } /// /// Update the ItemSource /// - public void Refresh(); + void Refresh(); /// /// Update the ItemSource asynchronously /// - public Task RefreshAsync(CancellationToken token); + Task RefreshAsync(CancellationToken token); + + /// + /// Checks if the collection has been initialized. + /// Will only trigger a refresh if the collection has not been initialized yet. + /// + /// + /// Maximum time to wait for an asynchronous refresh to complete. + /// If not specified, defaults to 3 seconds. + /// + /// + /// + void EnsureInitialized(TimeSpan? maxWaitTime = null); + + /// + /// Checks if the collection has been initialized. + /// Will only trigger a refresh if the collection has not been initialized yet. + /// + /// + /// + Task EnsureInitializedAsync(CancellationToken token); /// /// Public EventHandler method to allow triggering the refresh via another object's event /// - public void Refresh(object? sender, EventArgs e); + void Refresh(object? sender, EventArgs e); /// /// Public EventHandler to allow triggering the refresh via a routed event /// - public void Refresh(object? sender, RoutedEventArgs e); + void Refresh(object? sender, RoutedEventArgs e); } } diff --git a/src/Mvvm.Controls/Mvvm/Primitives/ItemSource.cs b/src/Mvvm.Controls/Mvvm/Primitives/ItemSource.cs index 496a7fe..3e0caf8 100644 --- a/src/Mvvm.Controls/Mvvm/Primitives/ItemSource.cs +++ b/src/Mvvm.Controls/Mvvm/Primitives/ItemSource.cs @@ -136,11 +136,11 @@ public virtual TList Items OnItemSourceChanged(EventArgs.Empty); OnPropertyChanged(EventArgSingletons.Items); + // Invoke the optional action passed to the constructor + _onCollectionChanged?.Invoke(); + // invoke derived class requirements OnItemsChanged(); - - // Invoke the optional action passed to the constructor - _onCollectionChanged?.Invoke(); } } } diff --git a/src/Mvvm.Controls/Mvvm/Primitives/SelectorDefinition.cs b/src/Mvvm.Controls/Mvvm/Primitives/SelectorDefinition.cs index d983fed..fe9256b 100644 --- a/src/Mvvm.Controls/Mvvm/Primitives/SelectorDefinition.cs +++ b/src/Mvvm.Controls/Mvvm/Primitives/SelectorDefinition.cs @@ -42,6 +42,7 @@ public SelectorDefinition(Action? onCollectionChanged = null, Action? onSelectio private int? _selectedIndex = null; private bool _indexChanging; private bool _selectedValueChanging; + private bool _collectionChanging = false; /// /// Occurs after the has been updated @@ -116,11 +117,6 @@ public TSelectedValue SelectedValue } } - ///// - ///// Enable/Disable auto-updating of item and SelectedIndex if bound by the ItemSource binding definition attached property - ///// - //internal bool IsBoundByBehavior { get; set; } - /// /// The index of the currently selected item within the ItemSource. /// @@ -141,14 +137,15 @@ static int getIndex(T? selection, TList list) } set { - if (_selectedIndex == value) + + if (!_collectionChanging && _selectedIndex == value) { return; // no change; } - else if (value >= 0 && (Items?.Count ?? 0) == 0) // deselect the item + else if (value >= 0 && (Items?.Count ?? 0) == 0) // throw if greater than collection count { // No items in collection - throw new ArgumentOutOfRangeException($"SelectedIndex property can not be set when the collection has no items", nameof(SelectedIndex)); + throw new ArgumentOutOfRangeException(nameof(value), $"SelectedIndex property can not be set when the collection has no items"); } else if (value < Items.Count) { @@ -178,25 +175,36 @@ static int getIndex(T? selection, TList list) RaiseSelectedValueChanged(oldSelectedValue); } _indexChanging = false; + _collectionChanging = false; } else { - throw new ArgumentOutOfRangeException($"SelectedIndex property was set to a value outside the valid range (expected value between -1 and number of items in the collection ( currently: {Items.Count} )", nameof(SelectedIndex)); + throw new ArgumentOutOfRangeException(nameof(value), $"SelectedIndex property was set to a value outside the valid range (expected value between -1 and number of items in the collection ( currently: {Items.Count} )"); } } } - /// + /// + /// Flag that the collection is changing, + /// so that the SelectedIndex and SelectedItem can be updated accordingly in + /// protected override void OnItemsChanging() { - base.OnItemsChanging(); + _collectionChanging = true; } - /// + + /// + /// Sets the SelectedIndex to -1 if the collection has changed and the SelectedIndex or SelectedItem has not been updated during that collection change. + /// Also sets the SelectedIndex to -1 if the collection is cleared ( count == 0 ) or if the SelectedItem is null. + /// protected override void OnItemsChanged() { - SelectedIndex = -1; - base.OnItemsChanged(); + if (_collectionChanging || SelectedItem is null || Items.Count == 0) + { + SelectedIndex = -1; + } + _collectionChanging = false; } /// @@ -204,6 +212,10 @@ protected override void OnItemsChanged() /// partial void OnSelectedItemChanging(T value) { + // reset the flag that would reset the SelectedIndex to -1 + // this accommodates scenarios where OnCollectionChanged is used to select an item from the new collection. + _collectionChanging = false; + if (_indexChanging || _selectedValueChanging) return; // changing event raised in index setter OnPropertyChanging(EventArgSingletons.SelectedIndex); OnPropertyChanging(EventArgSingletons.SelectedValue); @@ -231,6 +243,7 @@ partial void OnSelectedItemChanged(T? oldValue, T newValue) OnPropertyChanged(EventArgSingletons.SelectedValue); RaiseSelectedValueChanged(oldSelectedValue); } + // invoke the constructor supplied action _onSelectionChanged?.Invoke(); } diff --git a/src/Mvvm.Controls/Mvvm/RefreshableSelector.cs b/src/Mvvm.Controls/Mvvm/RefreshableSelector.cs index 63d4ecb..f82c69f 100644 --- a/src/Mvvm.Controls/Mvvm/RefreshableSelector.cs +++ b/src/Mvvm.Controls/Mvvm/RefreshableSelector.cs @@ -135,6 +135,58 @@ private set } } + private static async Task WaitForTaskOrCancellationAsync(Task task, CancellationToken token) + { + if (task.Status < TaskStatus.RanToCompletion) + { + var tcs = new TaskCompletionSource(); + using var reg = token.Register(() => tcs.SetCanceled()); + var completedTask = await Task.WhenAny(tcs.Task, task); // returns when cancellation is requested or the refresh task completes + if (completedTask == tcs.Task) + { + token.ThrowIfCancellationRequested(); + } + } + } + + private async Task AwaitLatestExecutionTaskAsync(IAsyncRelayCommand asyncCommand, CancellationToken token) + { + while (asyncCommand.ExecutionTask is Task task) + { + await WaitForTaskOrCancellationAsync(task, token); + + try + { + await task; + return; + } + catch (OperationCanceledException) when (!token.IsCancellationRequested) + { + var currentTask = asyncCommand.ExecutionTask; + if (ReferenceEquals(currentTask, task) && !asyncCommand.IsRunning && !IsRefreshing) + { + await asyncCommand.ExecuteAsync(token); + return; + } + + // Another refresh has superseded this one (or is still in-flight). + // Continue waiting for the latest execution task. + } + } + } + + private async Task RefreshAsyncInternal(IAsyncRelayCommand asyncCmd, CancellationToken token) + { + using var cancellationReg = token.Register(asyncCmd.Cancel); + if (asyncCmd.IsRunning) + { + await AwaitLatestExecutionTaskAsync(asyncCmd, token); + } + else + { + await asyncCmd.ExecuteAsync(token); + } + } /// public IRelayCommand RefreshCommand => _refreshCommand ??= CreateCommand(); @@ -149,7 +201,7 @@ private IRelayCommand CreateCommand() } else if (_cancellableRefreshAsync is not null) { - return new AsyncRelayCommand(RefreshTask, _canRefresh, AsyncRelayCommandOptions.None); + return new AsyncRelayCommand(RefreshTask, _canRefresh, AsyncRelayCommandOptions.FlowExceptionsToTaskScheduler); } return InactiveButton.Instance; } @@ -331,18 +383,7 @@ public async Task EnsureInitializedAsync(CancellationToken token) } else if (RefreshCommand is IAsyncRelayCommand asyncCommand && asyncCommand.ExecutionTask is not null) { - var task = asyncCommand.ExecutionTask; - if (task.Status < TaskStatus.RanToCompletion) - { - var tcs = new TaskCompletionSource(); - using var reg = token.Register(() => tcs.SetCanceled()); - var completedTask = await Task.WhenAny(tcs.Task, task); // returns when cancellation is requested or the refresh task completes - if (completedTask == tcs.Task) - { - token.ThrowIfCancellationRequested(); - } - } - await task; + await AwaitLatestExecutionTaskAsync(asyncCommand, token); } else if (IsRefreshing) @@ -354,6 +395,20 @@ public async Task EnsureInitializedAsync(CancellationToken token) } } } + catch (OperationCanceledException) when (!token.IsCancellationRequested && RefreshCommand is IAsyncRelayCommand) + { + if (RefreshCommand is IAsyncRelayCommand asyncCommand) + { + try + { + await AwaitLatestExecutionTaskAsync(asyncCommand, token); + } + catch (OperationCanceledException) when (!token.IsCancellationRequested) + { + ResetInitializedState(); + } + } + } catch (OperationCanceledException) { ResetInitializedState(); throw; } catch (RefreshFailedException) { ResetInitializedState(); throw; } catch (Exception e) @@ -400,12 +455,7 @@ public Task RefreshAsync(CancellationToken token) if (RefreshCommand is IAsyncRelayCommand asyncCmd) { - using var cancellationReg = token.Register(asyncCmd.Cancel); - if (asyncCmd.IsRunning) - { - return asyncCmd.ExecutionTask!; - } - return asyncCmd.ExecuteAsync(token); + return RefreshAsyncInternal(asyncCmd, token); } else if (_refresh is not null) { diff --git a/tests/.Playlists/RefreshableSelectorTests.playlist b/tests/.Playlists/RefreshableSelectorTests.playlist new file mode 100644 index 0000000..7167039 --- /dev/null +++ b/tests/.Playlists/RefreshableSelectorTests.playlist @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/MvvmControlsTests/CSharp9_MissingComponents.cs b/tests/MvvmControlsTests/CSharp9_MissingComponents.cs index 21b7c1b..e37157b 100644 --- a/tests/MvvmControlsTests/CSharp9_MissingComponents.cs +++ b/tests/MvvmControlsTests/CSharp9_MissingComponents.cs @@ -4,14 +4,6 @@ using System.ComponentModel; -#if NETFRAMEWORK -namespace System.Diagnostics.CodeAnalysis -{ - [AttributeUsage(validOn: AttributeTargets.Parameter)] - internal class NotNullAttribute : Attribute { } -} -#endif - #if !NET5_0_OR_GREATER namespace ClassLibrary { diff --git a/tests/MvvmControlsTests/Mvvm.Tests/RefreshableSelectorSequenceTests.cs b/tests/MvvmControlsTests/Mvvm.Tests/RefreshableSelectorSequenceTests.cs new file mode 100644 index 0000000..e673379 --- /dev/null +++ b/tests/MvvmControlsTests/Mvvm.Tests/RefreshableSelectorSequenceTests.cs @@ -0,0 +1,311 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace RFBCodeWorks.Mvvm.Tests +{ + [TestClass] + [DoNotParallelize] + public class RefreshableSelectorSequenceTests + { + public TestContext TestContext { get; set; } + + private CancellationToken Token => TestContext.CancellationToken; + + +#pragma warning disable CA1859 // Intentionally tests via the interface + + private IDisposable? tokenRegistration; + private IRefreshableSelector Selector1 = null!; + private IRefreshableSelector Selector2 = null!; + private IRefreshableSelector Selector3 = null!; + + private int selector2RefreshCount; + private int selector3RefreshCount; + private int selector1SelectedItemChangedCount; + private int selector2SelectedItemChangedCount; + private int selector3SelectedItemChangedCount; + + private int s2_cancelCount = 0; + private int s3_cancelCount = 0; + +#pragma warning restore CA1859 + + private static int[] GetIntegers() => [5, 4, 3, 2, 1, 0]; + + private static void SelectIfOnlyOneItem(IRefreshableSelector selector) + { + if (selector.Items.Count == 1) + selector.SelectedIndex = 0; + } + + private static void SelectFirstItem(IRefreshableSelector selector) + { + if (selector.Items.Count > 0) + selector.SelectedIndex = 0; + } + + [ClassInitialize] + public static void ClassInitialize(TestContext context) + { } + + [TestCleanup] + public void TestCleanup() + { + tokenRegistration?.Dispose(); + } + + [TestInitialize] + public async Task TestInitialize() + { + int delayPeriod = 200; + + selector2RefreshCount = 0; + selector3RefreshCount = 0; + selector1SelectedItemChangedCount = 0; + selector2SelectedItemChangedCount = 0; + selector3SelectedItemChangedCount = 0; + s2_cancelCount = 0; + s3_cancelCount = 0; + + // selector 1's refresh is triggered on initialization and when .Items is requested for first time. + // When Selector1.SelectedItem changes, it triggers selector 2's refresh + Selector1 = new RefreshableSelector( + async token => + { + await Task.Delay(delayPeriod, TestContext.CancellationToken); + return GetIntegers(); + }, + onCollectionChanged: () => SelectFirstItem(Selector1), + onSelectionChanged: () => + { + selector1SelectedItemChangedCount++; + Selector2.RefreshCommand.Execute(null); + }, + refreshOnFirstCollectionRequest: true) + { + DisplayMemberPath = nameof(Selector1), + }; + + // selector 2's refresh is triggered by selector 1's selection change + Selector2 = new RefreshableSelector( + async token => + { + try + { + selector2RefreshCount++; + var i = selector2RefreshCount; + await Task.Delay(delayPeriod, token); + return [i + 1, i + 2, i + 3]; + } + catch (OperationCanceledException) + { + s2_cancelCount++; + throw; + } + }, + onCollectionChanged: () => SelectFirstItem(Selector2), + onSelectionChanged: () => + { + selector2SelectedItemChangedCount++; + Selector3.RefreshCommand.Execute(null); + }, + refreshOnFirstCollectionRequest: false) + { + DisplayMemberPath = nameof(Selector2), + }; + + // selector 3's refresh is triggered by selector 2's selection change + Selector3 = new RefreshableSelector( + async token => + { + try + { + selector3RefreshCount++; + var i = selector3RefreshCount; + await Task.Delay(delayPeriod, token); + return [i]; + } + catch (OperationCanceledException) + { + s3_cancelCount++; + throw; + } + }, + onCollectionChanged: () => SelectFirstItem(Selector3), + onSelectionChanged: () => + { + selector3SelectedItemChangedCount++; + }, + refreshOnFirstCollectionRequest: false) + { + DisplayMemberPath = nameof(Selector3), + }; + + tokenRegistration = Token.Register(() => + { + Selector1.CancelRefreshCommand.Execute(null); + Selector2.CancelRefreshCommand.Execute(null); + Selector3.CancelRefreshCommand.Execute(null); + }); + } + + + /// + /// Selector 1 has had its collection updated, triggering refresh on selector 2. + /// Verifies that Selector 2 has received 2 successful refreshes and calls its OnCollectionChanged logic the correct number of times + /// + [TestMethod] + [Timeout(10000, CooperativeCancellation =true)] + public Task Test_MultipleRefreshesOnSelector2() + { + return Run_MultipleRefreshesOnSelector2(); + } + + private async Task Run_MultipleRefreshesOnSelector2() + { + // trigger selector 1 async refresh + Assert.HasCount(0, Selector1.Items, "\n >> Selector1 should report 0 items initially"); + Assert.IsTrue(Selector1.IsRefreshing, "\n >> Selector1 should be refreshing after initialization"); + await Selector1.EnsureInitializedAsync(Token); + Assert.IsFalse(Selector1.IsRefreshing, "\n >> Selector1 should not be refreshing after initialization completes"); + Assert.HasCount(6, Selector1.Items, "\n >> Selector1 should report 6 items after refresh"); + Assert.AreEqual(0, Selector1.SelectedIndex, "\n >> Selector1 should have selected index 0 after refresh"); + + Assert.IsTrue(Selector2.IsRefreshing, "\n >> Selector2 should be refreshing after Selector1's first refresh"); + await Selector2.EnsureInitializedAsync(Token); // ensure selector 2 has completed its refresh triggered by selector 1's selection change + await Selector3.EnsureInitializedAsync(Token); + Assert.AreEqual(1, selector2RefreshCount); + Assert.AreEqual(1, selector2SelectedItemChangedCount); + + // selector 2 should now be refreshing for the second time (since first refresh was triggered when selector 1 had selectedIndex set to 0 via OnCollectionChanged) + Selector1.SelectedIndex = 1; + Assert.AreEqual(2, selector1SelectedItemChangedCount, "\n >> Selector1 should have had its selection changed 2 times (Once from first refresh, second explicitly)"); + + Assert.IsTrue(Selector2.IsRefreshing, "\n >> Selector2 should be refreshing after Selector1 selection change"); + await Selector2.EnsureInitializedAsync(Token); + await Selector3.EnsureInitializedAsync(Token); + Assert.IsFalse(Selector2.IsRefreshing, "\n >> Selector2 should not be refreshing after initialization completes"); + + Assert.AreEqual(0, Selector2.SelectedIndex, "\n >> Selector2 should have selected index 0"); + Assert.AreEqual(2, selector2RefreshCount, "\n >> Selector2 should have refreshed 2 times (One cancelled, one completed)"); + Assert.AreEqual(2, selector2SelectedItemChangedCount, "\n >> Selector2 should have had its selection changed 2 times (once per successful refresh)"); + Assert.AreEqual(3, Selector2.SelectedItem, "\n >> Selector2 value should = 3 (number of refreshes + 1)"); + } + + /// + /// this test performs the same steps as 'Test_MultipleRefreshesOnSelector2', + /// but then continues to change the selected index of selector 2 to trigger a refresh on selector 3, + /// to ensure that the refreshes are happening in the correct sequence and that the correct number of refreshes are being triggered for each selector. + /// + [TestMethod] + [Timeout(10000, CooperativeCancellation = true)] + public async Task Test_MultipleRefreshesOnSelector3() + { + + await Run_MultipleRefreshesOnSelector2(); + // assert results from the previous call to ensure expected state + Assert.AreEqual(2, selector2RefreshCount, "\n >> Selector2 is in incorrect state"); + Assert.AreEqual(0, Selector2.SelectedIndex, "\n >> Selector2 is in incorrect state"); + Assert.AreEqual(2, selector2SelectedItemChangedCount, "\n >> Selector2 is in incorrect state"); + Assert.AreEqual(3, Selector2.SelectedItem, "\n >> Selector2 is in incorrect state"); + Assert.IsFalse(Selector2.IsRefreshing, "\n >> Selector2 should not be refreshing prior to explicit change"); + + + // select 3 should now be on its 2nd refresh, since select2 has had its item changed 2 times. + await Selector3.EnsureInitializedAsync(Token); + Assert.AreEqual(selector2SelectedItemChangedCount, selector3RefreshCount, "\n >> Selector3 should have refreshed 2 times from selector 2's selection changes"); + + // select 3 should now be on its 3rd refresh, since select2 has had its item changed 3 times. + Selector2.SelectedIndex = 2; + Assert.AreEqual(3, selector2SelectedItemChangedCount, @" +>> Selector2 should have had its selection changed 3 times: + >> Once from first refresh of selector1 when Selector1 performed OnSelectionChanged (selected index 0) + >> second from selector 1 explicitly changed SelectedIndex to 1 -> selector 2 refreshes -> selector 2 OnCollectionChanged selects index 0 + >> third from explicit change"); + + Assert.IsTrue(Selector3.IsRefreshing, "\n >> Selector3 should be refreshing after Selector2 selection change"); + await Selector3.EnsureInitializedAsync(Token); + Assert.IsFalse(Selector3.IsRefreshing, "\n >> Selector3 should not be refreshing after initialization completes"); + + Assert.AreEqual(3, selector3RefreshCount, @" +>> Selector3 should have refreshed 3 times: + >> 1st = Selector1 Selected item -> Selector2 refresh -> Selector2 OnCollectionChanged selected index 0 -> Selector3 refresh + >> 2nd = Selector1 Changed SelectedIndex to 1 -> Selector2 refresh (cancelling previous) -> Selector2 OnCollectionChanged selected an item -> Selector3 refresh + >> 3rd = Selector2 Changed SelectedIndex to 2 -> Selector3 refresh"); + Assert.AreEqual(0, Selector3.SelectedIndex, "\n >> Selector3 should have selected index 0"); + Assert.AreEqual(3, Selector3.SelectedItem, "\n >> Selector3 should have selected item 3 (equal to number of refreshes)"); + } + + /// + /// Tests a complex interaction between selectors that are refreshing in response to each other's collection and selection changes, + /// to ensure that the refreshes are happening in the correct sequence and that the correct number of refreshes are being triggered for each selector. + /// Also ensures that the 'EnsureInitializedAsync' method correctly waits for the last active refresh to complete. + /// + /// + /// Primary difference between this and the other tests is that the refresh for Selectors 2 and 3 is cancelled + /// instead of waiting for completion prior to updated the prior selector's SelectedIndex. + /// + [TestMethod] + [Timeout(10000, CooperativeCancellation = true)] + public async Task Test_MultipleRefreshWithCancellation() + { + /* Overview + * "ProcessViewModel()" here represents an async call that occurred on the viewmodel after binding was established + * + * Sequence: + * - View binds to Selector1, triggering its refresh + * - ProcessViewModel() ensures Selector1 is initialized, then selects an item from the collection + * - Selector1's selection change triggers a refresh on Selector2 (Cancelling the previous refresh if it hasn't completed yet) + * - Since the first refresh is cancelled, Selector3 should not refresh yet + * - ProcessViewModel() then ensures Selector2 is initialized (which should be against the second collection) and selects an item from that collection + * - Selector3 should have now had 2 refreshes triggered, the first of which is cancelled due to selector 2's selection change + * - ProcessViewModel() exits after ensuring Selector3 is initialized against the second refresh + */ + + // trigger selector 1 async refresh + Assert.HasCount(0, Selector1.Items, "\n >> Selector1 should report 0 items initially"); + Assert.IsTrue(Selector1.IsRefreshing, "\n >> Selector1 should be refreshing after initialization"); + await Selector1.EnsureInitializedAsync(Token); + Assert.IsFalse(Selector1.IsRefreshing, "\n >> Selector1 should not be refreshing after initialization completes"); + Assert.HasCount(6, Selector1.Items, "\n >> Selector1 should report 6 items after refresh"); + Assert.AreEqual(0, Selector1.SelectedIndex, "\n >> Selector1 should have selected index 0 after refresh"); + + // selector 2 should now be refreshing for the second time (since first refresh was triggered when selector 1 had selectedIndex set to 0 via OnCollectionChanged) + // The first refresh should have been cancelled. + Selector1.SelectedIndex = 1; + Assert.AreEqual(2, selector1SelectedItemChangedCount, "\n >> Selector1 should have had its selection changed 2 times (first cancelled, second success)"); + + Assert.IsTrue(Selector2.IsRefreshing, "\n >> Selector2 should be refreshing after Selector1 selection change"); + await Selector2.EnsureInitializedAsync(Token); + Assert.IsFalse(Selector2.IsRefreshing, "\n >> Selector2 should not be refreshing after initialization completes"); + + Assert.AreEqual(2, selector2RefreshCount, "\n >> Selector2 should have refreshed 2 times (One cancelled, one completed)"); + Assert.AreEqual(1, s2_cancelCount, "\n >> Selector2 should have cancelled 1 refresh"); + Assert.AreEqual(0, Selector2.SelectedIndex, "\n >> Selector2 should have selected index 0"); + Assert.AreEqual(1, selector2SelectedItemChangedCount, "\n >> Selector2 should have had its selection changed 2 times (once per successful refresh)"); + Assert.AreEqual(3, Selector2.SelectedItem, "\n >> Selector2 value should = 3 (number of refreshes + 1)"); + + // select 3 should now be on its 3rd refresh, since select2 has had its item changed 3 times. + Selector2.SelectedIndex = 2; + Assert.AreEqual(2, selector2SelectedItemChangedCount, @" +>> Selector2 should have had its selection changed 2 times: + >> Once after first successful refresh + >> second from selector 1 explicitly changed SelectedIndex to 2 + "); + Assert.IsTrue(Selector3.IsRefreshing, "\n >> Selector3 should be refreshing after Selector2 selection change"); + await Selector3.EnsureInitializedAsync(Token); + Assert.IsFalse(Selector3.IsRefreshing, "\n >> Selector3 should not be refreshing after initialization completes"); + + Assert.AreEqual(2, selector3RefreshCount, $@" +>> Selector3 should have refreshed 2 times: + >> 1st = Selector2 successful refresh -> Selector2 OnCollectionChanged selected index 0 -> Selector3 refresh + >> 2nd = Selector2 Changed SelectedIndex to 2 -> Selector3 refresh"); + + Assert.AreEqual(1, s3_cancelCount, "\n >> Selector3 should have cancelled 1 refresh"); + Assert.AreEqual(0, Selector3.SelectedIndex, "\n >> Selector3 should have selected index 0"); + Assert.AreEqual(2, Selector3.SelectedItem, "\n >> Selector3 should have selected item 2 (equal to number of refreshes)"); + } + } +} diff --git a/tests/MvvmControlsTests/Mvvm.Tests/RefreshableSelectorTests.cs b/tests/MvvmControlsTests/Mvvm.Tests/RefreshableSelectorTests.cs index 1ddb9e3..53a0feb 100644 --- a/tests/MvvmControlsTests/Mvvm.Tests/RefreshableSelectorTests.cs +++ b/tests/MvvmControlsTests/Mvvm.Tests/RefreshableSelectorTests.cs @@ -259,16 +259,35 @@ public Task Create(CancellationToken token) } [TestMethod] - public async Task Test_RefreshAsync_MultipleCalls_ReturnsRunningTask() + public async Task Test_RefreshAsync_MultipleCalls_DoesNotCancelRunningTask() { - var tcs = new TaskCompletionSource(); - var selector = new RefreshableSelector(() => tcs.Task); + List > tcsList = []; + + bool anyTaskStarted = false; + bool anyTaskCancelled = false; + Func> refreshAsync = (token) => + { + var tcs = new TaskCompletionSource(); + token.Register(() => + { + anyTaskCancelled = true; + tcs.TrySetCanceled(token); + }); + anyTaskStarted = true; + tcsList.Add(tcs); + return tcs.Task; + }; + + var selector = new RefreshableSelector(refreshAsync); var firstTask = selector.RefreshAsync(Token); var secondTask = selector.RefreshAsync(Token); - Assert.AreSame(firstTask, secondTask); - tcs.TrySetResult(GetIntegers()); - Assert.HasCount(6, await tcs.Task); + Assert.IsTrue(anyTaskStarted); + Assert.IsFalse(anyTaskCancelled); + Assert.HasCount(1, tcsList); + tcsList[0].SetResult(GetIntegers()); + await firstTask; + await secondTask; } [TestMethod]