diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListPageFetchPhase.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListPageFetchPhase.cs new file mode 100644 index 0000000000..c83e2b3457 --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListPageFetchPhase.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels; + +// Advance only after each milestone succeeds. An interrupted earlier phase +// deliberately asks for more recovery, never for less than the page needs. +internal enum ListPageFetchPhase +{ + // Includes requests deferred while suspended, before a worker claims them. + Fetching, + + // Items owns the snapshot, but FilteredItems/ItemsUpdated have not caught up. + Committed, + + // Back needs only to restart unfinished initialization of retained rows. + Published, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListPageWorkState.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListPageWorkState.cs new file mode 100644 index 0000000000..3688c064af --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListPageWorkState.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels; + +/// +/// Atomically owns the current visit/fetch and its remaining work. Navigation +/// invalidates the generation while retaining the phase and publication intent, +/// so even a fetch still blocked in extension code can be recovered on Back. +/// +internal sealed record ListPageWorkState( + int Generation, + ListPageWorkStatus Status, + ListPageFetchPhase Phase, + bool KeepSelection = true, + bool EnsureSelectionVisible = false); diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListPageWorkStatus.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListPageWorkStatus.cs new file mode 100644 index 0000000000..6e681b1e6d --- /dev/null +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListPageWorkStatus.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +namespace Microsoft.CmdPal.UI.ViewModels; + +internal enum ListPageWorkStatus +{ + Active, + Suspended, + Stopped, +} diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs index 75f9752657..e6fd0ea5b2 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ListViewModel.cs @@ -62,7 +62,6 @@ public partial class ListViewModel : PageViewModel, IDisposable private InterlockedBoolean _isLoadingMore; private int _activeFetchCount; - private int _latestFetchGeneration; private bool _deferredFetchRequested; private bool _deferredFetchKeepSelection = true; private bool _deferredFetchEnsureSelectionVisible; @@ -106,7 +105,11 @@ public partial class ListViewModel : PageViewModel, IDisposable private Task? _initializeItemsTask; private ListItemInitializationCoordinator? _itemInitializationCoordinator; - private int _initializationStopped; + // Navigation may suspend and later restore this VM from the Frame back stack. + // Dispose/SafeCleanup are terminal; resumption must never undo either of them. + private ListPageWorkState _workState = new(0, ListPageWorkStatus.Active, ListPageFetchPhase.Published); + + private bool IsWorkActive => Volatile.Read(ref _workState).Status == ListPageWorkStatus.Active; // For cancelling the task to load the properties from the items in the list private CancellationTokenSource? _cancellationTokenSource; @@ -242,6 +245,11 @@ public partial class ListViewModel : PageViewModel, IDisposable private void RequestFetch(bool keepSelection, bool ensureSelectionVisible) { + if (DeferFetchWhileInactive(keepSelection, ensureSelectionVisible)) + { + return; + } + // Keep RPC GetItems work off the UI thread. If the provider raises // ItemsChanged while we're already on a background thread, stay on that // thread so same-thread reentrancy detection still works. @@ -291,9 +299,9 @@ public partial class ListViewModel : PageViewModel, IDisposable } } - private static void QueueObservedBackgroundFetch(Action action, string logMessage) + private static Task QueueObservedBackgroundFetch(Action action, string logMessage) { - _ = Task.Run( + return Task.Run( () => { try @@ -311,27 +319,40 @@ public partial class ListViewModel : PageViewModel, IDisposable } //// Run on background thread, from InitializeAsync or Model_ItemsChanged - private void FetchItems(bool keepSelection, bool ensureSelectionVisible) + private void FetchItems(bool keepSelection, bool ensureSelectionVisible, int? recoveryGeneration = null) { System.Diagnostics.Debug.Assert(!IsCurrentThreadUiThread(), "FetchItems should not run on the UI thread."); - // If this fetch should reset selection, remember that intent even if - // a later incremental fetch cancels us. - if (!keepSelection) - { - _forceFirstItemPending = true; - } - CancellationToken cancellationToken; int fetchGeneration; lock (_fetchStateLock) { - // Cancel any previous FetchItems operation - CancelAndDisposeTokenSource(ref _fetchItemsCancellationTokenSource); - _fetchItemsCancellationTokenSource = new CancellationTokenSource(); + if (!TryBeginFetch(keepSelection, ensureSelectionVisible, recoveryGeneration, out var work)) + { + return; + } - cancellationToken = _fetchItemsCancellationTokenSource.Token; - fetchGeneration = Interlocked.Increment(ref _latestFetchGeneration); + fetchGeneration = work.Generation; + if (!work.KeepSelection) + { + _forceFirstItemPending = true; + } + + // Capture the token before publishing its owner: navigation can cancel + // and dispose the source without acquiring this background fetch lock. + var fetchCancellation = new CancellationTokenSource(); + cancellationToken = fetchCancellation.Token; + var previousCancellation = Interlocked.Exchange(ref _fetchItemsCancellationTokenSource, fetchCancellation); + CancelAndDisposeTokenSource(ref previousCancellation); + if (!IsCurrentFetch(fetchGeneration)) + { + if (Interlocked.CompareExchange(ref _fetchItemsCancellationTokenSource, null, fetchCancellation) == fetchCancellation) + { + fetchCancellation.Dispose(); + } + + return; + } } // Declared outside try so catch blocks can reference them @@ -441,6 +462,8 @@ public partial class ListViewModel : PageViewModel, IDisposable lock (_listLock) { + ThrowIfFetchCanceledOrStale(fetchGeneration, cancellationToken); + // Now that we have new ViewModels for everything from the // extension, smartly update our list of VMs ListHelpers.InPlaceUpdateList(Items, newViewModels, out removedItems); @@ -454,6 +477,7 @@ public partial class ListViewModel : PageViewModel, IDisposable } itemsTransferredToList = true; + AdvanceFetchPhase(fetchGeneration, ListPageFetchPhase.Committed); // If we removed items, we need to clean them up, to remove our event handlers foreach (var removedItem in removedItems) @@ -502,60 +526,70 @@ public partial class ListViewModel : PageViewModel, IDisposable } StartItemInitialization(fetchGeneration, cancellationToken); + QueueItemsPublication(fetchGeneration); + } - DoOnUiThread( - () => + private void QueueItemsPublication(int fetchGeneration) + { + DoOnUiThread(() => + { + lock (_fetchStateLock) { - lock (_fetchStateLock) + if (!IsCurrentFetch(fetchGeneration)) { - if (Volatile.Read(ref _initializationStopped) != 0 || !IsLatestFetchGeneration(fetchGeneration)) - { - return; - } - - lock (_listLock) - { - if (!IsLatestFetchGeneration(fetchGeneration)) - { - return; - } - - // Now that our Items contains everything we want, it's time for us to - // re-evaluate our Filter on those items. - if (!_isDynamic) - { - // A static list? Great! Just run the filter. - RunFilteredItemsUpdate(ApplyFilterUnderLock); - } - else - { - // A dynamic list? Even better! Just stick everything into - // FilteredItems. The extension already did any filtering it cared about. - var snapshot = Items.Where(i => !i.IsInErrorState).ToList(); - RunFilteredItemsUpdate(() => ListHelpers.InPlaceUpdateList(FilteredItems, snapshot)); - } - - UpdateEmptyContent(); - } - - if (!IsLatestFetchGeneration(fetchGeneration)) - { - return; - } - - // Consume the pending flag on the UI thread so a - // forceFirstItem=true intent survives cancellation. - var forceFirst = _forceFirstItemPending; - _forceFirstItemPending = false; - - ItemsUpdated?.Invoke( - this, - new ItemsUpdatedEventArgs( - forceFirstItem: IsRootPage && forceFirst, - ensureSelectionVisible: ensureSelectionVisible)); - _isLoadingMore.Clear(); + return; } - }); + + lock (_listLock) + { + // A deferred mutation is not a completed milestone. Keep its + // notification and phase advancement inside the guarded action. + RunFilteredItemsUpdate(() => PublishItemsUnderLock(fetchGeneration)); + } + } + }); + } + + private void PublishItemsUnderLock(int fetchGeneration) + { + // RunFilteredItemsUpdate's callers hold _listLock, including when a + // reentrant publication is deferred until an earlier mutation finishes. + var work = Volatile.Read(ref _workState); + if (work.Status != ListPageWorkStatus.Active || work.Generation != fetchGeneration) + { + return; + } + + // Reuse the same filtering/publication path after a fetch and when Back + // recovers a committed snapshot whose callback was cancelled. + if (!_isDynamic) + { + ApplyFilterUnderLock(); + } + else + { + var snapshot = Items.Where(i => !i.IsInErrorState).ToList(); + ListHelpers.InPlaceUpdateList(FilteredItems, snapshot); + } + + UpdateEmptyContent(); + if (!IsCurrentFetch(fetchGeneration)) + { + return; + } + + // Consume selection intent only when the retained snapshot reaches the + // UI, including a request originally received while suspended. + var forceFirst = _forceFirstItemPending || !work.KeepSelection; + _forceFirstItemPending = false; + + ItemsUpdated?.Invoke( + this, + new ItemsUpdatedEventArgs( + forceFirstItem: IsRootPage && forceFirst, + ensureSelectionVisible: work.EnsureSelectionVisible)); + _isLoadingMore.Clear(); + AdvanceFetchPhase(fetchGeneration, ListPageFetchPhase.Published); } private void StartItemInitialization(int fetchGeneration, CancellationToken fetchCancellationToken) @@ -564,8 +598,7 @@ public partial class ListViewModel : PageViewModel, IDisposable lock (_initializationCoordinatorLock) { - if (Volatile.Read(ref _initializationStopped) != 0 || fetchCancellationToken.IsCancellationRequested || - !IsLatestFetchGeneration(fetchGeneration)) + if (!IsCurrentFetch(fetchGeneration) || fetchCancellationToken.IsCancellationRequested) { return; } @@ -591,9 +624,10 @@ public partial class ListViewModel : PageViewModel, IDisposable previousCoordinator?.Stop(); CancelAndDisposeTokenSource(ref previousCancellation); - // Teardown never waits for the background-only lock. Close its race with - // publication here; a Stop after this check is also safe before Run starts. - if (Volatile.Read(ref _initializationStopped) != 0) + // Navigation/teardown never wait for the background-only lock. Recheck + // the generation too: a suspend/resume cycle must not revive this fetch. + // A Stop after this check is also safe before Run starts. + if (!IsCurrentFetch(fetchGeneration) || fetchCancellationToken.IsCancellationRequested) { coordinator.Stop(); Interlocked.CompareExchange(ref _itemInitializationCoordinator, null, coordinator); @@ -743,15 +777,16 @@ public partial class ListViewModel : PageViewModel, IDisposable private void ThrowIfFetchCanceledOrStale(int fetchGeneration, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - if (Volatile.Read(ref _latestFetchGeneration) != fetchGeneration) + if (!IsCurrentFetch(fetchGeneration)) { throw new OperationCanceledException(); } } - private bool IsLatestFetchGeneration(int fetchGeneration) + private bool IsCurrentFetch(int fetchGeneration) { - return Volatile.Read(ref _latestFetchGeneration) == fetchGeneration; + var work = Volatile.Read(ref _workState); + return work.Status == ListPageWorkStatus.Active && work.Generation == fetchGeneration; } private void PublishVmCache(Dictionary newCache) @@ -850,6 +885,11 @@ public partial class ListViewModel : PageViewModel, IDisposable private void SetSelectedItem(ListItemViewModel item) { + if (!IsWorkActive) + { + return; + } + _lastSelectedItem = item; _lastSelectedItem.PropertyChanged += SelectedItemPropertyChanged; @@ -1174,10 +1214,169 @@ public partial class ListViewModel : PageViewModel, IDisposable } } - public void Dispose() + // The shell serializes navigation transitions on the UI thread. Terminal + // cleanup may race them, but resumption can only transition Suspended -> Active. + internal void SuspendForNavigation() { - GC.SuppressFinalize(this); - Interlocked.Exchange(ref _initializationStopped, 1); + if (TryChangeWorkStatus(ListPageWorkStatus.Active, ListPageWorkStatus.Suspended) is not null) + { + CancelPendingWork(); + } + } + + internal Task ResumeAfterNavigation() + { + var work = TryChangeWorkStatus(ListPageWorkStatus.Suspended, ListPageWorkStatus.Active); + if (work is null) + { + return Task.CompletedTask; + } + + // Do not consume the recovery record when queueing: another navigation + // can invalidate this visit before the worker or UI callback ever runs. + return QueueObservedBackgroundFetch( + () => + { + if (!IsCurrentFetch(work.Generation)) + { + return; + } + + if (work.Phase == ListPageFetchPhase.Fetching) + { + FetchItems(work.KeepSelection, work.EnsureSelectionVisible, work.Generation); + } + else + { + StartItemInitialization(work.Generation, CancellationToken.None); + if (work.Phase == ListPageFetchPhase.Committed) + { + QueueItemsPublication(work.Generation); + } + } + }, + "Failed to resume list page after navigation"); + } + + private bool DeferFetchWhileInactive(bool keepSelection, bool ensureSelectionVisible) + { + while (true) + { + var work = Volatile.Read(ref _workState); + if (work.Status == ListPageWorkStatus.Active) + { + return false; + } + + if (work.Status == ListPageWorkStatus.Stopped) + { + return true; + } + + var pendingKeepSelection = work.KeepSelection && keepSelection; + var pendingEnsureSelectionVisible = work.EnsureSelectionVisible || ensureSelectionVisible; + var pending = work.Phase == ListPageFetchPhase.Fetching && + work.KeepSelection == pendingKeepSelection && + work.EnsureSelectionVisible == pendingEnsureSelectionVisible + ? work + : work with + { + Phase = ListPageFetchPhase.Fetching, + KeepSelection = pendingKeepSelection, + EnsureSelectionVisible = pendingEnsureSelectionVisible, + }; + + // Even an already-covered request verifies ownership with a no-op CAS: + // if resume won, this request must retry on the now-active page. + if (ReferenceEquals(Interlocked.CompareExchange(ref _workState, pending, work), work)) + { + return true; + } + + // Status and pending intent share one CAS. If resume won, retry sees + // Active and the caller fetches normally; no late flag can be stranded. + } + } + + private bool TryBeginFetch(bool keepSelection, bool ensureSelectionVisible, int? recoveryGeneration, out ListPageWorkState work) + { + while (true) + { + var previous = Volatile.Read(ref _workState); + work = previous; + if (recoveryGeneration.HasValue && previous.Generation != recoveryGeneration.Value) + { + return false; + } + + if (previous.Status != ListPageWorkStatus.Active) + { + if (DeferFetchWhileInactive(keepSelection, ensureSelectionVisible)) + { + return false; + } + + continue; + } + + work = new( + unchecked(previous.Generation + 1), + ListPageWorkStatus.Active, + ListPageFetchPhase.Fetching, + previous.KeepSelection && keepSelection, + previous.EnsureSelectionVisible || ensureSelectionVisible); + + // Use ReferenceEquals for all work-state CAS results: record == can + // mistake a distinct-but-equal snapshot for a successful exchange. + if (ReferenceEquals(Interlocked.CompareExchange(ref _workState, work, previous), previous)) + { + return true; + } + } + } + + private void AdvanceFetchPhase(int generation, ListPageFetchPhase phase) + { + var work = Volatile.Read(ref _workState); + if (work.Status != ListPageWorkStatus.Active || work.Generation != generation) + { + return; + } + + var completed = work with + { + Phase = phase, + KeepSelection = phase == ListPageFetchPhase.Published || work.KeepSelection, + EnsureSelectionVisible = phase != ListPageFetchPhase.Published && work.EnsureSelectionVisible, + }; + + // A failed CAS means another fetch or navigation owns recovery now. Never + // let a late unwind/commit/publication rewrite that owner's obligation. + Interlocked.CompareExchange(ref _workState, completed, work); + } + + private ListPageWorkState? TryChangeWorkStatus(ListPageWorkStatus from, ListPageWorkStatus to) + { + while (true) + { + var work = Volatile.Read(ref _workState); + if (work.Status != from) + { + return null; + } + + var next = work with { Status = to, Generation = unchecked(work.Generation + 1) }; + if (ReferenceEquals(Interlocked.CompareExchange(ref _workState, next, work), work)) + { + return next; + } + } + } + + private void CancelPendingWork() + { + // The status transition already invalidated callbacks and retained their + // unfinished phase atomically. Never take worker-owned locks on navigation. CancelAndDisposeTokenSource(ref _selectedItemCts); CancelAndDisposeTokenSource(ref _cancellationTokenSource); Interlocked.Exchange(ref _itemInitializationCoordinator, null)?.Stop(); @@ -1185,20 +1384,39 @@ public partial class ListViewModel : PageViewModel, IDisposable CancelAndDisposeTokenSource(ref _fetchItemsCancellationTokenSource); } + public void Dispose() + { + GC.SuppressFinalize(this); + StopWork(); + } + + private void StopWork() + { + while (true) + { + var work = Volatile.Read(ref _workState); + if (work.Status == ListPageWorkStatus.Stopped) + { + return; + } + + if (TryChangeWorkStatus(work.Status, ListPageWorkStatus.Stopped) is not null) + { + break; + } + } + + CancelPendingWork(); + } + protected override void UnsafeCleanup() { - Interlocked.Exchange(ref _initializationStopped, 1); + StopWork(); base.UnsafeCleanup(); EmptyContent?.SafeCleanup(); EmptyContent = new(new(null), PageContext, contextMenuFactory: null); // necessary? - CancelAndDisposeTokenSource(ref _selectedItemCts); - CancelAndDisposeTokenSource(ref _cancellationTokenSource); - Interlocked.Exchange(ref _itemInitializationCoordinator, null)?.Stop(); - CancelAndDisposeTokenSource(ref filterCancellationTokenSource); - CancelAndDisposeTokenSource(ref _fetchItemsCancellationTokenSource); - lock (_listLock) { foreach (var item in Items) diff --git a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs index ab47b069fe..ab85bf2e0f 100644 --- a/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs +++ b/src/modules/cmdpal/Microsoft.CmdPal.UI.ViewModels/ShellViewModel.cs @@ -67,16 +67,27 @@ public partial class ShellViewModel : ObservableObject, IsSearchBoxVisible = true; } - if (oldValue is IDisposable disposable) + try { - try + if (oldValue is ListViewModel previousList) + { + // Frame keeps the VM in its navigation parameter for Back. + // Cancel this visit's work without permanently disposing it. + previousList.SuspendForNavigation(); + } + else if (oldValue is IDisposable disposable) { disposable.Dispose(); } - catch (Exception ex) - { - CoreLogger.LogError(ex.ToString()); - } + } + catch (Exception ex) + { + CoreLogger.LogError(ex.ToString()); + } + + if (value is ListViewModel currentList) + { + _ = currentList.ResumeAfterNavigation(); } } } diff --git a/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/ListViewModelNavigationTests.cs b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/ListViewModelNavigationTests.cs new file mode 100644 index 0000000000..7d11178585 --- /dev/null +++ b/src/modules/cmdpal/Tests/Microsoft.CmdPal.UI.ViewModels.UnitTests/ListViewModelNavigationTests.cs @@ -0,0 +1,978 @@ +// Copyright (c) Microsoft Corporation +// The Microsoft Corporation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Diagnostics; +using System.Linq; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.Messaging; +using Microsoft.CommandPalette.Extensions; +using Microsoft.CommandPalette.Extensions.Toolkit; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; + +namespace Microsoft.CmdPal.UI.ViewModels.UnitTests; + +[TestClass] +[DoNotParallelize] +public sealed partial class ListViewModelNavigationTests +{ + private const string InitialGlyph = "\uE8D4"; + private const string SearchGlyph = "\uE8A5"; + + private sealed partial class TestHost : AppExtensionHost + { + public override string? GetExtensionDisplayName() => "Navigation test host"; + } + + private sealed partial class SearchPage : DynamicListPage + { + private IListItem[] _items = [CreateItem("Initial", InitialGlyph)]; + private int _getItemsCount; + + internal int GetItemsCount => Volatile.Read(ref _getItemsCount); + + internal Action? OnGetItems { get; set; } + + public override IListItem[] GetItems() + { + var count = Interlocked.Increment(ref _getItemsCount); + var items = Volatile.Read(ref _items); + OnGetItems?.Invoke(count); + return items; + } + + public override void UpdateSearchText(string oldSearch, string newSearch) => + ReplaceItems([CreateItem(newSearch, SearchGlyph)]); + + internal void ReplaceItems(IListItem[] items, bool notify = true) + { + Volatile.Write(ref _items, items); + if (notify) + { + RaiseItemsChanged(items.Length); + } + } + + internal void Refresh() => RaiseItemsChanged(); + } + + private sealed partial class StaticPage(IListItem[] items) : ListPage + { + private int _getItemsCount; + + internal int GetItemsCount => Volatile.Read(ref _getItemsCount); + + public override IListItem[] GetItems() + { + Interlocked.Increment(ref _getItemsCount); + return items; + } + } + + private sealed partial class TrackingItem(string title, ManualResetEventSlim? started = null, ManualResetEventSlim? release = null) + : ListItem(new NoOpCommand { Name = title }) + { + private int _initializationCount; + + internal int InitializationCount => Volatile.Read(ref _initializationCount); + + public override ITag[] Tags + { + get + { + Interlocked.Increment(ref _initializationCount); + started?.Set(); + if (release is not null && !release.Wait(TimeSpan.FromSeconds(5))) + { + throw new TimeoutException("The blocked item was not released."); + } + + return []; + } + + set + { + } + } + } + + private sealed class QueuedTaskScheduler : TaskScheduler + { + private readonly ConcurrentQueue _tasks = new(); + + protected override IEnumerable GetScheduledTasks() => _tasks.ToArray(); + + protected override void QueueTask(Task task) => _tasks.Enqueue(task); + + protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) => false; + + internal void Drain() + { + while (_tasks.TryDequeue(out var task)) + { + TryExecuteTask(task); + task.GetAwaiter().GetResult(); + } + } + + internal void DrainUntil(Func condition) + { + var elapsed = Stopwatch.StartNew(); + while (!condition()) + { + Drain(); + Assert.IsTrue(elapsed.Elapsed < TimeSpan.FromSeconds(3), "UI publication did not finish."); + Thread.Sleep(5); + } + } + } + + [DataTestMethod] + [DataRow(true)] + [DataRow(false)] + [Timeout(15000)] + public async Task ReturningToRetainedPagePublishesSearchResultsAndIcons(bool isRootPage) + { + var page = new SearchPage(); + var viewModel = CreateViewModel(page); + viewModel.IsRootPage = isRootPage; + using var shell = CreateShell(); + shell.CurrentPage = viewModel; + + try + { + await ObserveItemsAsync(viewModel, "Initial", viewModel.InitializeProperties); + Assert.AreEqual(InitialGlyph, viewModel.FilteredItems[0].Icon.Light.Icon); + + foreach (var query in new[] { "Word", "Excel", "PowerPoint" }) + { + await ObserveItemsAsync(viewModel, query, () => + { + // Exercise the same shell setter as Frame forward/back navigation: + // the navigation parameter retains and restores this exact VM. + shell.CurrentPage = shell.NullPage; + shell.CurrentPage = viewModel; + viewModel.SearchTextBox = query; + }); + + Assert.AreSame(viewModel, shell.CurrentPage); + Assert.AreEqual(SearchGlyph, viewModel.FilteredItems[0].Icon.Light.Icon); + } + } + finally + { + WeakReferenceMessenger.Default.UnregisterAll(shell); + viewModel.Dispose(); + viewModel.SafeCleanup(); + } + } + + [TestMethod] + [Timeout(15000)] + public async Task ReturningToStaticPageRestartsPendingRealizedIconsWithoutItemsChanged() + { + using var started = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var items = Enumerable.Range(0, 48).Select(index => (IListItem)CreateItem($"Item {index}")).ToArray(); + items[20] = new TrackingItem("Item 20", started, release) { Icon = new IconInfo(SearchGlyph) }; + var last = new TrackingItem("Item 47") { Icon = new IconInfo(SearchGlyph) }; + items[47] = last; + var page = new StaticPage(items); + var viewModel = CreateViewModel(page); + using var shell = CreateShell(); + shell.CurrentPage = viewModel; + ListItemRealizationRegistration registration = default; + Task? oldWorker = null; + + try + { + await ObserveItemsAsync(viewModel, vm => vm.FilteredItems.Count == 48, viewModel.InitializeProperties); + await WaitForPublishedAsync(viewModel); + Assert.IsTrue(started.Wait(TimeSpan.FromSeconds(2))); + oldWorker = GetPrivateField(viewModel, "_itemInitializationCoordinator").Completion; + var pending = viewModel.FilteredItems.Single(item => ReferenceEquals(item.Model.Unsafe, last)); + Assert.AreEqual(0, last.InitializationCount); + Assert.IsFalse(pending.Icon.HasIcon(light: true)); + + shell.CurrentPage = shell.NullPage; + release.Set(); + await oldWorker.WaitAsync(TimeSpan.FromSeconds(2)); + + // The page has no ItemsChanged event to restart work for us. A new + // visual tree can publish realization before the shell restores its VM. + registration = pending.BeginRealization(); + Assert.IsTrue(registration.IsValid); + shell.CurrentPage = viewModel; + Assert.IsTrue(await pending.WaitForInitializationAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(3))); + + Assert.AreEqual(1, last.InitializationCount); + Assert.AreEqual(SearchGlyph, pending.Icon.Light.Icon); + Assert.IsTrue(viewModel.FilteredItems.Contains(pending)); + Assert.AreEqual(1, page.GetItemsCount, "Restarting retained rows must not refetch an unchanged page."); + } + finally + { + release.Set(); + registration.Release(); + WeakReferenceMessenger.Default.UnregisterAll(shell); + viewModel.Dispose(); + if (oldWorker is not null) + { + await oldWorker.WaitAsync(TimeSpan.FromSeconds(2)); + } + + viewModel.SafeCleanup(); + } + } + + [TestMethod] + [Timeout(15000)] + public async Task FetchFromPreviousVisitCannotOverwriteResultsAfterReturn() + { + using var started = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var page = new SearchPage(); + var viewModel = CreateViewModel(page); + using var shell = CreateShell(); + shell.CurrentPage = viewModel; + Task? oldFetch = null; + + try + { + await ObserveItemsAsync(viewModel, "Initial", viewModel.InitializeProperties); + page.OnGetItems = count => + { + if (count == 2) + { + started.Set(); + Assert.IsTrue(release.Wait(TimeSpan.FromSeconds(5))); + } + }; + + oldFetch = Task.Run(() => page.ReplaceItems([CreateItem("Obsolete")])); + Assert.IsTrue(started.Wait(TimeSpan.FromSeconds(2))); + shell.CurrentPage = shell.NullPage; + page.ReplaceItems([CreateItem("Current")]); + Assert.AreEqual(2, page.GetItemsCount, "A hidden page must not restart fetching on ItemsChanged."); + + await ObserveItemsAsync(viewModel, "Current", () => shell.CurrentPage = viewModel); + release.Set(); + await oldFetch.WaitAsync(TimeSpan.FromSeconds(2)); + + Assert.AreEqual("Current", viewModel.FilteredItems.Single().Title); + Assert.AreEqual(SearchGlyph, viewModel.FilteredItems.Single().Icon.Light.Icon); + Assert.AreEqual(3, page.GetItemsCount); + } + finally + { + release.Set(); + if (oldFetch is not null) + { + await oldFetch.WaitAsync(TimeSpan.FromSeconds(2)); + } + + WeakReferenceMessenger.Default.UnregisterAll(shell); + viewModel.Dispose(); + viewModel.SafeCleanup(); + } + } + + [TestMethod] + [Timeout(15000)] + public void QueuedPublicationFromPreviousVisitStaysInvalidAfterResume() + { + var scheduler = new QueuedTaskScheduler(); + var page = new SearchPage(); + var viewModel = CreateViewModel(page, scheduler); + using var shell = CreateShell(); + shell.CurrentPage = viewModel; + var publications = 0; + void OnItemsUpdated(ListViewModel sender, ItemsUpdatedEventArgs args) => publications++; + + try + { + viewModel.InitializeProperties(); + scheduler.DrainUntil(() => viewModel.FilteredItems.Count == 1); + scheduler.Drain(); + viewModel.ItemsUpdated += OnItemsUpdated; + page.ReplaceItems([CreateItem("Obsolete")]); + shell.CurrentPage = shell.NullPage; + page.ReplaceItems([CreateItem("Current")]); + + // Hold off the resumed background fetch before it can increment the + // generation itself. Run queued UI work reentrantly on this test thread + // to prove suspension invalidated the old callback, not just the next fetch. + using (GetPrivateField(viewModel, "_fetchStateLock").EnterScope()) + { + shell.CurrentPage = viewModel; + scheduler.Drain(); + Assert.AreEqual(0, publications, "Resumption must not make a previous visit's callback current again."); + } + + scheduler.DrainUntil(() => publications == 1); + Assert.AreEqual("Current", viewModel.FilteredItems.Single().Title); + } + finally + { + viewModel.ItemsUpdated -= OnItemsUpdated; + WeakReferenceMessenger.Default.UnregisterAll(shell); + viewModel.Dispose(); + scheduler.Drain(); + viewModel.SafeCleanup(); + } + } + + [DataTestMethod] + [DataRow(false)] + [DataRow(true)] + [Timeout(15000)] + public void TerminalCleanupCannotBeReversedByBackNavigation(bool useSafeCleanup) + { + var scheduler = new QueuedTaskScheduler(); + var page = new SearchPage(); + var viewModel = CreateViewModel(page, scheduler); + using var shell = CreateShell(); + shell.CurrentPage = viewModel; + + try + { + viewModel.InitializeProperties(); + scheduler.DrainUntil(() => viewModel.FilteredItems.Count == 1); + shell.CurrentPage = shell.NullPage; + if (useSafeCleanup) + { + viewModel.SafeCleanup(); + } + else + { + viewModel.Dispose(); + } + + shell.CurrentPage = viewModel; + page.ReplaceItems([CreateItem("Must not load")]); + scheduler.Drain(); + Assert.AreEqual(1, page.GetItemsCount); + Assert.AreEqual(useSafeCleanup ? 0 : 1, viewModel.FilteredItems.Count); + } + finally + { + WeakReferenceMessenger.Default.UnregisterAll(shell); + viewModel.Dispose(); + scheduler.Drain(); + viewModel.SafeCleanup(); + } + } + + [TestMethod] + [Timeout(15000)] + public async Task NavigationHandoffDoesNotWaitForWorkerOwnedLocks() + { + using var held = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var page = new SearchPage(); + var viewModel = CreateViewModel(page); + using var shell = CreateShell(); + shell.CurrentPage = viewModel; + Task? lockHolder = null; + Task? navigation = null; + + try + { + await ObserveItemsAsync(viewModel, "Initial", viewModel.InitializeProperties); + lockHolder = Task.Run(() => + { + using (GetPrivateField(viewModel, "_initializationCoordinatorLock").EnterScope()) + using (GetPrivateField(viewModel, "_fetchStateLock").EnterScope()) + using (GetPrivateField(viewModel, "_listLock").EnterScope()) + { + held.Set(); + Assert.IsTrue(release.Wait(TimeSpan.FromSeconds(5))); + } + }); + + Assert.IsTrue(held.Wait(TimeSpan.FromSeconds(2))); + navigation = Task.Run(() => + { + shell.CurrentPage = shell.NullPage; + shell.CurrentPage = viewModel; + }); + await navigation.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.IsFalse(lockHolder.IsCompleted, "Navigation must finish while the worker still owns the locks."); + } + finally + { + release.Set(); + if (lockHolder is not null) + { + await lockHolder.WaitAsync(TimeSpan.FromSeconds(2)); + } + + if (navigation is not null) + { + await navigation.WaitAsync(TimeSpan.FromSeconds(2)); + } + + WeakReferenceMessenger.Default.UnregisterAll(shell); + viewModel.Dispose(); + viewModel.SafeCleanup(); + } + } + + [TestMethod] + [Timeout(15000)] + public async Task ReturningToUnchangedPageDoesNotFetchOrRepublish() + { + var scheduler = new QueuedTaskScheduler(); + var page = new SearchPage(); + var viewModel = CreateViewModel(page, scheduler); + var publications = 0; + void OnItemsUpdated(ListViewModel sender, ItemsUpdatedEventArgs args) => publications++; + + try + { + viewModel.InitializeProperties(); + scheduler.Drain(); + var retained = viewModel.FilteredItems.Single(); + viewModel.ItemsUpdated += OnItemsUpdated; + + for (var visit = 0; visit < 3; visit++) + { + viewModel.SuspendForNavigation(); + await viewModel.ResumeAfterNavigation(); + scheduler.Drain(); + } + + Assert.AreEqual(1, page.GetItemsCount); + Assert.AreEqual(0, publications); + Assert.AreSame(retained, viewModel.FilteredItems.Single()); + } + finally + { + viewModel.ItemsUpdated -= OnItemsUpdated; + viewModel.Dispose(); + scheduler.Drain(); + viewModel.SafeCleanup(); + } + } + + [TestMethod] + [Timeout(15000)] + public async Task DirectInitialFetchDeferredBySuspensionIsRecovered() + { + var scheduler = new QueuedTaskScheduler(); + var page = new SearchPage(); + var viewModel = CreateViewModel(page, scheduler); + + try + { + viewModel.SuspendForNavigation(); + viewModel.InitializeProperties(); // Calls FetchItems directly, not RequestFetch. + Assert.AreEqual(0, page.GetItemsCount); + Assert.AreEqual(ListPageFetchPhase.Fetching, GetWorkState(viewModel).Phase); + + await viewModel.ResumeAfterNavigation(); + scheduler.Drain(); + Assert.AreEqual(1, page.GetItemsCount); + Assert.AreEqual("Initial", viewModel.FilteredItems.Single().Title); + Assert.AreEqual(ListPageFetchPhase.Published, GetWorkState(viewModel).Phase); + } + finally + { + viewModel.Dispose(); + scheduler.Drain(); + viewModel.SafeCleanup(); + } + } + + [DataTestMethod] + [DataRow(false)] + [DataRow(true)] + [Timeout(15000)] + public async Task SupersededFetchDoesNotCreateRecoveryWhenItUnwinds(bool finishWhileSuspended) + { + using var started = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var scheduler = new QueuedTaskScheduler(); + var page = new SearchPage(); + var viewModel = CreateViewModel(page, scheduler); + Task? oldFetch = null; + + try + { + viewModel.InitializeProperties(); + scheduler.Drain(); + page.OnGetItems = count => + { + if (count == 2) + { + started.Set(); + Assert.IsTrue(release.Wait(TimeSpan.FromSeconds(5))); + } + }; + + oldFetch = Task.Run(() => page.ReplaceItems([CreateItem("Obsolete")])); + Assert.IsTrue(started.Wait(TimeSpan.FromSeconds(2))); + page.ReplaceItems([CreateItem("Current")]); + scheduler.Drain(); + Assert.AreEqual(ListPageFetchPhase.Published, GetWorkState(viewModel).Phase); + + if (finishWhileSuspended) + { + viewModel.SuspendForNavigation(); + } + + release.Set(); + await oldFetch.WaitAsync(TimeSpan.FromSeconds(2)); + if (!finishWhileSuspended) + { + viewModel.SuspendForNavigation(); + } + + await viewModel.ResumeAfterNavigation(); + scheduler.Drain(); + Assert.AreEqual(3, page.GetItemsCount, "A superseded fetch must not resurrect a satisfied request."); + Assert.AreEqual("Current", viewModel.FilteredItems.Single().Title); + } + finally + { + release.Set(); + if (oldFetch is not null) + { + await oldFetch.WaitAsync(TimeSpan.FromSeconds(2)); + } + + viewModel.Dispose(); + scheduler.Drain(); + viewModel.SafeCleanup(); + } + } + + [TestMethod] + [Timeout(15000)] + public async Task InterruptedFetchIsRecoveredBeforeTheOldGetItemsReturns() + { + using var started = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var scheduler = new QueuedTaskScheduler(); + var page = new SearchPage(); + var viewModel = CreateViewModel(page, scheduler); + Task? oldFetch = null; + + try + { + viewModel.InitializeProperties(); + scheduler.Drain(); + page.OnGetItems = count => + { + if (count == 2) + { + started.Set(); + Assert.IsTrue(release.Wait(TimeSpan.FromSeconds(5))); + } + }; + + oldFetch = Task.Run(() => page.ReplaceItems([CreateItem("Current")])); + Assert.IsTrue(started.Wait(TimeSpan.FromSeconds(2))); + viewModel.SuspendForNavigation(); + + // No ItemsChanged while suspended, and the cancelled call has not + // unwound: recovery must already know that the snapshot is unfinished. + await viewModel.ResumeAfterNavigation(); + scheduler.Drain(); + Assert.IsFalse(oldFetch.IsCompleted); + Assert.AreEqual("Current", viewModel.FilteredItems.Single().Title); + Assert.AreEqual(3, page.GetItemsCount); + + release.Set(); + await oldFetch.WaitAsync(TimeSpan.FromSeconds(2)); + viewModel.SuspendForNavigation(); + await viewModel.ResumeAfterNavigation(); + scheduler.Drain(); + Assert.AreEqual(3, page.GetItemsCount, "The late unwind must not create another recovery fetch."); + } + finally + { + release.Set(); + if (oldFetch is not null) + { + await oldFetch.WaitAsync(TimeSpan.FromSeconds(2)); + } + + viewModel.Dispose(); + scheduler.Drain(); + viewModel.SafeCleanup(); + } + } + + [TestMethod] + [Timeout(15000)] + public async Task CommittedSnapshotIsRepublishedWithoutGetItemsAndPreservesSelectionIntent() + { + var scheduler = new QueuedTaskScheduler(); + var page = new SearchPage(); + var viewModel = CreateViewModel(page, scheduler); + viewModel.IsRootPage = true; + var publications = new List(); + void OnItemsUpdated(ListViewModel sender, ItemsUpdatedEventArgs args) => publications.Add(args); + + try + { + viewModel.InitializeProperties(); + scheduler.Drain(); + viewModel.ItemsUpdated += OnItemsUpdated; + page.ReplaceItems([CreateItem("Current")]); + Assert.AreEqual(ListPageFetchPhase.Committed, GetWorkState(viewModel).Phase); + Assert.AreEqual("Initial", viewModel.FilteredItems.Single().Title); + + viewModel.SuspendForNavigation(); + await viewModel.ResumeAfterNavigation(); + scheduler.Drain(); + + Assert.AreEqual(2, page.GetItemsCount); + Assert.AreEqual("Current", viewModel.FilteredItems.Single().Title); + Assert.AreEqual(1, publications.Count); + Assert.IsTrue(publications[0].ForceFirstItem); + Assert.IsTrue(publications[0].EnsureSelectionVisible); + Assert.AreEqual(ListPageFetchPhase.Published, GetWorkState(viewModel).Phase); + + viewModel.SuspendForNavigation(); + await viewModel.ResumeAfterNavigation(); + scheduler.Drain(); + Assert.AreEqual(1, publications.Count, "A recovered publication must not be repeated on the next Back."); + } + finally + { + viewModel.ItemsUpdated -= OnItemsUpdated; + viewModel.Dispose(); + scheduler.Drain(); + viewModel.SafeCleanup(); + } + } + + [TestMethod] + [Timeout(15000)] + public async Task SuspendedFetchRequestsSubsumeACommittedSnapshot() + { + var scheduler = new QueuedTaskScheduler(); + var page = new SearchPage(); + var viewModel = CreateViewModel(page, scheduler); + + try + { + viewModel.InitializeProperties(); + scheduler.Drain(); + page.ReplaceItems([CreateItem("Committed but superseded")]); + viewModel.SuspendForNavigation(); + page.ReplaceItems([CreateItem("First suspended change")]); + page.ReplaceItems([CreateItem("Latest suspended change")]); + Assert.AreEqual(2, page.GetItemsCount); + + await viewModel.ResumeAfterNavigation(); + scheduler.Drain(); + Assert.AreEqual(3, page.GetItemsCount, "Suspended requests should reconcile in one fetch."); + Assert.AreEqual("Latest suspended change", viewModel.FilteredItems.Single().Title); + } + finally + { + viewModel.Dispose(); + scheduler.Drain(); + viewModel.SafeCleanup(); + } + } + + [TestMethod] + [Timeout(15000)] + public async Task CancelledProviderFetchRemainsRecoverable() + { + var scheduler = new QueuedTaskScheduler(); + var page = new SearchPage(); + var viewModel = CreateViewModel(page, scheduler); + + try + { + viewModel.InitializeProperties(); + scheduler.Drain(); + page.OnGetItems = count => + { + if (count == 2) + { + throw new OperationCanceledException(); + } + }; + page.ReplaceItems([CreateItem("Current")]); + Assert.AreEqual(ListPageFetchPhase.Fetching, GetWorkState(viewModel).Phase); + + viewModel.SuspendForNavigation(); + await viewModel.ResumeAfterNavigation(); + scheduler.Drain(); + Assert.AreEqual("Current", viewModel.FilteredItems.Single().Title); + Assert.AreEqual(3, page.GetItemsCount); + } + finally + { + viewModel.Dispose(); + scheduler.Drain(); + viewModel.SafeCleanup(); + } + } + + [DataTestMethod] + [DataRow("Fetching")] + [DataRow("Committed")] + [DataRow("Published")] + [Timeout(15000)] + public async Task RepeatedNavigationBeforeRecoveryRunsPreservesTheRequiredPhase(string phaseName) + { + using var held = new ManualResetEventSlim(); + using var release = new ManualResetEventSlim(); + var phase = Enum.Parse(phaseName); + var scheduler = new QueuedTaskScheduler(); + var page = new SearchPage(); + var viewModel = CreateViewModel(page, scheduler); + Task? lockHolder = null; + Task? firstResume = null; + Task? secondResume = null; + + try + { + viewModel.InitializeProperties(); + scheduler.Drain(); + if (phase == ListPageFetchPhase.Committed) + { + page.ReplaceItems([CreateItem("Current")]); + } + + viewModel.SuspendForNavigation(); + if (phase == ListPageFetchPhase.Fetching) + { + page.ReplaceItems([CreateItem("Current")]); + } + + lockHolder = Task.Run(() => + { + using (GetPrivateField(viewModel, "_initializationCoordinatorLock").EnterScope()) + using (GetPrivateField(viewModel, "_fetchStateLock").EnterScope()) + { + held.Set(); + Assert.IsTrue(release.Wait(TimeSpan.FromSeconds(5))); + } + }); + Assert.IsTrue(held.Wait(TimeSpan.FromSeconds(2))); + + firstResume = viewModel.ResumeAfterNavigation(); + viewModel.SuspendForNavigation(); + secondResume = viewModel.ResumeAfterNavigation(); + Assert.AreEqual(phase, GetWorkState(viewModel).Phase, "Queueing recovery must not consume it."); + release.Set(); + await Task.WhenAll(lockHolder, firstResume, secondResume).WaitAsync(TimeSpan.FromSeconds(3)); + scheduler.Drain(); + + Assert.AreEqual(phase == ListPageFetchPhase.Published ? 1 : 2, page.GetItemsCount); + Assert.AreEqual(phase == ListPageFetchPhase.Published ? "Initial" : "Current", viewModel.FilteredItems.Single().Title); + Assert.AreEqual(ListPageFetchPhase.Published, GetWorkState(viewModel).Phase); + } + finally + { + release.Set(); + foreach (var task in new[] { lockHolder, firstResume, secondResume }) + { + if (task is not null) + { + await task.WaitAsync(TimeSpan.FromSeconds(3)); + } + } + + viewModel.Dispose(); + scheduler.Drain(); + viewModel.SafeCleanup(); + } + } + + [TestMethod] + [Timeout(15000)] + public async Task ItemsChangedRacingResumeIsNotLost() + { + var scheduler = new QueuedTaskScheduler(); + var page = new SearchPage(); + var viewModel = CreateViewModel(page, scheduler); + + try + { + viewModel.InitializeProperties(); + scheduler.Drain(); + for (var visit = 1; visit <= 30; visit++) + { + using var begin = new ManualResetEventSlim(); + var title = $"Visit {visit}"; + viewModel.SuspendForNavigation(); + page.ReplaceItems([CreateItem(title)], notify: false); + var request = Task.Run(() => + { + Assert.IsTrue(begin.Wait(TimeSpan.FromSeconds(2))); + page.Refresh(); + }); + var resume = Task.Run(async () => + { + Assert.IsTrue(begin.Wait(TimeSpan.FromSeconds(2))); + await viewModel.ResumeAfterNavigation(); + }); + begin.Set(); + await Task.WhenAll(request, resume).WaitAsync(TimeSpan.FromSeconds(3)); + scheduler.Drain(); + Assert.AreEqual(title, viewModel.FilteredItems.Single().Title); + Assert.AreEqual(visit + 1, page.GetItemsCount); + } + } + finally + { + viewModel.Dispose(); + scheduler.Drain(); + viewModel.SafeCleanup(); + } + } + + [TestMethod] + [Timeout(15000)] + public void ReentrantPublicationDoesNotAdvanceThePhaseBeforeItsMutationRuns() + { + var scheduler = new QueuedTaskScheduler(); + var page = new SearchPage(); + var viewModel = CreateViewModel(page, scheduler); + var reentered = false; + var publications = 0; + void OnItemsUpdated(ListViewModel sender, ItemsUpdatedEventArgs args) => publications++; + void OnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs args) + { + if (reentered) + { + return; + } + + reentered = true; + page.ReplaceItems([CreateItem("Inner")]); + scheduler.Drain(); // Models WinUI pumping a queued callback during mutation. + Assert.AreEqual(ListPageFetchPhase.Committed, GetWorkState(viewModel).Phase); + Assert.AreEqual(0, publications, "A deferred mutation must not report successful publication."); + } + + try + { + viewModel.InitializeProperties(); + scheduler.Drain(); + viewModel.ItemsUpdated += OnItemsUpdated; + viewModel.FilteredItems.CollectionChanged += OnCollectionChanged; + page.ReplaceItems([CreateItem("Outer")]); + scheduler.Drain(); + + Assert.IsTrue(reentered); + Assert.AreEqual("Inner", viewModel.FilteredItems.Single().Title); + Assert.AreEqual(1, publications); + Assert.AreEqual(ListPageFetchPhase.Published, GetWorkState(viewModel).Phase); + } + finally + { + viewModel.ItemsUpdated -= OnItemsUpdated; + viewModel.FilteredItems.CollectionChanged -= OnCollectionChanged; + viewModel.Dispose(); + scheduler.Drain(); + viewModel.SafeCleanup(); + } + } + + [TestMethod] + [Timeout(15000)] + public async Task DuplicateSuspendedRequestsReuseTheirPendingRecord() + { + var scheduler = new QueuedTaskScheduler(); + var page = new SearchPage(); + var viewModel = CreateViewModel(page, scheduler); + + try + { + viewModel.InitializeProperties(); + scheduler.Drain(); + viewModel.SuspendForNavigation(); + page.ReplaceItems([CreateItem("Current")]); + var pending = GetWorkState(viewModel); + for (var request = 0; request < 30; request++) + { + page.Refresh(); + Assert.AreSame(pending, GetWorkState(viewModel)); + } + + await viewModel.ResumeAfterNavigation(); + scheduler.Drain(); + Assert.AreEqual(2, page.GetItemsCount); + Assert.AreEqual("Current", viewModel.FilteredItems.Single().Title); + } + finally + { + viewModel.Dispose(); + scheduler.Drain(); + viewModel.SafeCleanup(); + } + } + + private static ListPageWorkState GetWorkState(ListViewModel viewModel) => + GetPrivateField(viewModel, "_workState"); + + private static async Task WaitForPublishedAsync(ListViewModel viewModel) + { + var elapsed = Stopwatch.StartNew(); + while (GetWorkState(viewModel).Phase != ListPageFetchPhase.Published) + { + Assert.IsTrue(elapsed.Elapsed < TimeSpan.FromSeconds(3), "The fetch did not finish publishing."); + await Task.Delay(1); + } + } + + private static ListItem CreateItem(string title, string glyph = SearchGlyph) => + new(new NoOpCommand { Name = title }) { Icon = new IconInfo(glyph) }; + + private static ListViewModel CreateViewModel(IListPage page, TaskScheduler? scheduler = null) => + new(page, scheduler ?? TaskScheduler.Default, new TestHost(), CommandProviderContext.Empty, DefaultContextMenuFactory.Instance); + + private static ShellViewModel CreateShell() + { + var hostService = new Mock(); + hostService.Setup(service => service.GetDefaultHost()).Returns(new TestHost()); + return new(TaskScheduler.Default, Mock.Of(), Mock.Of(), hostService.Object); + } + + private static T GetPrivateField(ListViewModel viewModel, string name) => + (T)(typeof(ListViewModel).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)?.GetValue(viewModel) + ?? throw new AssertFailedException($"Missing field {name}.")); + + private static Task ObserveItemsAsync(ListViewModel viewModel, string expectedTitle, Action action) => + ObserveItemsAsync(viewModel, vm => vm.FilteredItems.Count == 1 && vm.FilteredItems[0].Title == expectedTitle, action); + + private static async Task ObserveItemsAsync(ListViewModel viewModel, Func predicate, Action action) + { + var published = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + void OnItemsUpdated(ListViewModel sender, ItemsUpdatedEventArgs args) + { + if (predicate(sender)) + { + published.TrySetResult(); + } + } + + viewModel.ItemsUpdated += OnItemsUpdated; + try + { + action(); + await published.Task.WaitAsync(TimeSpan.FromSeconds(3)); + } + finally + { + viewModel.ItemsUpdated -= OnItemsUpdated; + } + } +}