Me stupid. Me forgot stage. Fix CmdPal list-page recovery after Back navigation

- Separate navigation suspension from permanent disposal.
- Preserve fetch phase and selection intent across navigation.
- Recover pending work without refetching unchanged pages.
This commit is contained in:
Jiří Polášek
2026-08-28 12:24:02 +02:00
parent 4e0fb41f1f
commit 6d1a78f9d1
6 changed files with 1346 additions and 91 deletions

View File

@@ -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,
}

View File

@@ -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;
/// <summary>
/// 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.
/// </summary>
internal sealed record ListPageWorkState(
int Generation,
ListPageWorkStatus Status,
ListPageFetchPhase Phase,
bool KeepSelection = true,
bool EnsureSelectionVisible = false);

View File

@@ -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,
}

View File

@@ -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<IListItem, ListItemViewModel> 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)

View File

@@ -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();
}
}
}

View File

@@ -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<int>? 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<Task> _tasks = new();
protected override IEnumerable<Task> 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<bool> 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<ListItemInitializationCoordinator>(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<Lock>(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<Lock>(viewModel, "_initializationCoordinatorLock").EnterScope())
using (GetPrivateField<Lock>(viewModel, "_fetchStateLock").EnterScope())
using (GetPrivateField<Lock>(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<ItemsUpdatedEventArgs>();
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<ListPageFetchPhase>(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<Lock>(viewModel, "_initializationCoordinatorLock").EnterScope())
using (GetPrivateField<Lock>(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<ListPageWorkState>(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<IAppHostService>();
hostService.Setup(service => service.GetDefaultHost()).Returns(new TestHost());
return new(TaskScheduler.Default, Mock.Of<IRootPageService>(), Mock.Of<IPageViewModelFactoryService>(), hostService.Object);
}
private static T GetPrivateField<T>(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<ListViewModel, bool> 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;
}
}
}