Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ version numbers, because that is what those releases were called.

## [Unreleased]

### Fixed: WPF Timeline interaction and accessibility

- Added mouse double-click activation alongside Enter for Timeline restore points, with duplicate-open
protection and a readable fallback when a failed snapshot has no diagnostic detail.
- Moved Timeline catalog reads off the UI thread and added explicit loading and failure states so slow
or unavailable snapshot storage does not freeze the window or masquerade as an empty Timeline.
- Stacked the comparison evidence, restore set, and detail cards at narrow window widths while keeping
the established two-column workspace at wider sizes.
- Honored the Windows client-area animation preference for indeterminate progress, exposed stable theme
option automation IDs, and marked inactive alternative content as offscreen for assistive technology.

### Fixed: WPF lifecycle-safe navigation

- Added explicit Timeline and Advanced history destinations to the command bar so every read-only
Expand Down
49 changes: 44 additions & 5 deletions src/WinRestoreKit.Tests/TimelineAccessibilityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@ namespace WinRestoreKit.Tests
public sealed class TimelineAccessibilityTests
{
[Fact]
public void TimelineView_ExposesEquivalentNamedListAndKeyboardSelection()
public async Task TimelineView_ExposesEquivalentMouseAndKeyboardActivation()
{
WpfTestHost.Run(() =>
await WpfTestHost.RunAsync(async () =>
{
FakePreparationService service = new FakePreparationService();
TimelineViewModel viewModel = NewTimelineViewModel(service);
Expand All @@ -31,20 +31,28 @@ public void TimelineView_ExposesEquivalentNamedListAndKeyboardSelection()
host.Show();
try
{
await viewModel.RefreshAsync();
Layout(view);

ListBox list = Assert.IsType<ListBox>(view.FindName("TimelineEventList"));
Assert.Equal("Snapshots", AutomationProperties.GetName(list));
Assert.Equal("TimelineEventList", AutomationProperties.GetAutomationId(list));
Assert.Equal(SelectionMode.Single, list.SelectionMode);
Assert.Equal(KeyboardNavigationMode.Continue, KeyboardNavigation.GetDirectionalNavigation(list));
Assert.Contains("Enter", AutomationProperties.GetHelpText(list));
Assert.Contains("Double-click", AutomationProperties.GetHelpText(list));
list.SelectedIndex = 0;
RaiseKey(list, Key.Right);
Assert.Equal(1, list.SelectedIndex);
RaiseKey(list, Key.Left);
Assert.Equal(0, list.SelectedIndex);
RaiseKey(list, Key.Enter);
Assert.Equal(1, service.Calls);

ListBoxItem selected = Assert.IsType<ListBoxItem>(
list.ItemContainerGenerator.ContainerFromIndex(0));
RaiseDoubleClick(selected);
Assert.Equal(2, service.Calls);
}
finally
{
Expand All @@ -54,9 +62,9 @@ public void TimelineView_ExposesEquivalentNamedListAndKeyboardSelection()
}

[Fact]
public void TimelineView_ExposesEveryRowStateAndSelectionFailureAsAccessibleText()
public async Task TimelineView_ExposesOnlyTheActiveAlternateStateToAccessibility()
{
WpfTestHost.Run(() =>
await WpfTestHost.RunAsync(async () =>
{
TimelineViewModel viewModel = NewTimelineViewModel();
TimelineView view = new TimelineView { DataContext = viewModel };
Expand All @@ -65,6 +73,7 @@ public void TimelineView_ExposesEveryRowStateAndSelectionFailureAsAccessibleText
host.Show();
try
{
await viewModel.RefreshAsync();
Layout(view);

ListBox list = Assert.IsType<ListBox>(view.FindName("TimelineEventList"));
Expand All @@ -77,6 +86,27 @@ public void TimelineView_ExposesEveryRowStateAndSelectionFailureAsAccessibleText

TextBlock selectionError = Assert.IsType<TextBlock>(view.FindName("SelectionErrorText"));
Assert.Equal(AutomationLiveSetting.Polite, AutomationProperties.GetLiveSetting(selectionError));

Border empty = Assert.IsType<Border>(view.FindName("TimelineEmptyState"));
Border loading = Assert.IsType<Border>(view.FindName("TimelineLoadingState"));
Border error = Assert.IsType<Border>(view.FindName("TimelineSelectionError"));
Assert.Equal(Visibility.Collapsed, empty.Visibility);
Assert.Equal(IsOffscreenBehavior.Offscreen,
AutomationProperties.GetIsOffscreenBehavior(empty));
Assert.Equal(Visibility.Collapsed, loading.Visibility);
Assert.Equal(IsOffscreenBehavior.Offscreen,
AutomationProperties.GetIsOffscreenBehavior(loading));
Assert.Equal(Visibility.Collapsed, error.Visibility);
Assert.Equal(IsOffscreenBehavior.Offscreen,
AutomationProperties.GetIsOffscreenBehavior(error));

viewModel.SelectedEvent = viewModel.Events[0];
await viewModel.OpenSelectedAsync();
Layout(view);

Assert.Equal(Visibility.Visible, error.Visibility);
Assert.Equal(IsOffscreenBehavior.Onscreen,
AutomationProperties.GetIsOffscreenBehavior(error));
}
finally
{
Expand All @@ -91,7 +121,6 @@ private static TimelineViewModel NewTimelineViewModel(FakePreparationService ser
SnapshotEvent failed = NewEvent(SnapshotEventKind.Failed, @"C:\timeline\failed", "TEST-PC", "disk full");
TimelineViewModel viewModel = new TimelineViewModel(
new FakeCatalog(verified, failed), service ?? new FakePreparationService(), new RecordingNavigator());
viewModel.RefreshAsync().GetAwaiter().GetResult();
return viewModel;
}

Expand All @@ -109,6 +138,16 @@ private static void RaiseKey(UIElement target, Key key)
});
}

private static void RaiseDoubleClick(Control target)
{
PresentationSource inputSource = PresentationSource.FromVisual(target);
Assert.NotNull(inputSource);
target.RaiseEvent(new MouseButtonEventArgs(Mouse.PrimaryDevice, 0, MouseButton.Left)
{
RoutedEvent = Control.MouseDoubleClickEvent
});
}

private static void Layout(FrameworkElement element)
{
element.Measure(new Size(1024, 720));
Expand Down
126 changes: 95 additions & 31 deletions src/WinRestoreKit.Tests/TimelineViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Threading.Tasks;
using WinRestoreKit;
using WinRestoreKit.Wpf.Navigation;
using WinRestoreKit.Wpf.Services;
using WinRestoreKit.Wpf.ViewModels.Snapshots;
using WinRestoreKit.Wpf.ViewModels.Timeline;
using Xunit;
Expand All @@ -16,49 +17,91 @@ public sealed class TimelineViewModelTests
[Fact]
public async Task OpenSelectedAsync_PreparesPartialAndTransfersOwnershipToNavigator()
{
SnapshotEvent partial = NewEvent(SnapshotEventKind.Partial, @"C:\snapshot");
RecordingNavigator navigator = new RecordingNavigator();
TimelineViewModel viewModel = new TimelineViewModel(
new FakeCatalog(partial), new FakePreparationService(partial), navigator);

await viewModel.RefreshAsync();
viewModel.SelectedEvent = Assert.Single(viewModel.Events);
await viewModel.OpenSelectedAsync();

Assert.Same(partial, navigator.Prepared.Snapshot);
Assert.Null(navigator.Diagnostic);
navigator.Prepared.Dispose();
await WpfTestHost.RunAsync(async () =>
{
SnapshotEvent partial = NewEvent(SnapshotEventKind.Partial, @"C:\snapshot");
RecordingNavigator navigator = new RecordingNavigator();
TimelineViewModel viewModel = new TimelineViewModel(
new FakeCatalog(partial), new FakePreparationService(partial), navigator);

await viewModel.RefreshAsync();
viewModel.SelectedEvent = Assert.Single(viewModel.Events);
await viewModel.OpenSelectedAsync();

Assert.Same(partial, navigator.Prepared.Snapshot);
Assert.Null(navigator.Diagnostic);
navigator.Prepared.Dispose();
});
}

[Fact]
public async Task OpenSelectedAsync_ShowsFailedEvidenceWithoutPreparingPayload()
{
SnapshotEvent failed = NewEvent(SnapshotEventKind.Failed, @"C:\failed", "disk full");
FakePreparationService service = new FakePreparationService();
RecordingNavigator navigator = new RecordingNavigator();
TimelineViewModel viewModel = new TimelineViewModel(new FakeCatalog(failed), service, navigator);
await WpfTestHost.RunAsync(async () =>
{
SnapshotEvent failed = NewEvent(SnapshotEventKind.Failed, @"C:\failed", "disk full");
FakePreparationService service = new FakePreparationService();
RecordingNavigator navigator = new RecordingNavigator();
TimelineViewModel viewModel = new TimelineViewModel(new FakeCatalog(failed), service, navigator);

await viewModel.RefreshAsync();
viewModel.SelectedEvent = Assert.Single(viewModel.Events);
await viewModel.OpenSelectedAsync();

Assert.Same(failed, navigator.Diagnostic);
Assert.Equal(0, service.Calls);
});
}

[Fact]
public async Task OpenSelectedAsync_ReportsPreparationFailureInline()
{
await WpfTestHost.RunAsync(async () =>
{
SnapshotEvent verified = NewEvent(SnapshotEventKind.Verified, @"C:\verified");
TimelineViewModel viewModel = new TimelineViewModel(
new FakeCatalog(verified), new FakePreparationService(), new RecordingNavigator());

await viewModel.RefreshAsync();
viewModel.SelectedEvent = Assert.Single(viewModel.Events);
await viewModel.OpenSelectedAsync();
await viewModel.RefreshAsync();
viewModel.SelectedEvent = Assert.Single(viewModel.Events);
await viewModel.OpenSelectedAsync();

Assert.Same(failed, navigator.Diagnostic);
Assert.Equal(0, service.Calls);
Assert.True(viewModel.HasSelectionError);
Assert.Equal("unexpected preparation", viewModel.SelectionError);
});
}

[Fact]
public async Task OpenSelectedAsync_ReportsPreparationFailureInline()
public async Task RefreshAsync_ReadsCatalogAwayFromUiThread()
{
await WpfTestHost.RunAsync(async () =>
{
int uiThreadId = Thread.CurrentThread.ManagedThreadId;
FakeCatalog catalog = new FakeCatalog(
NewEvent(SnapshotEventKind.Verified, @"C:\verified"));
TimelineViewModel viewModel = new TimelineViewModel(
catalog, new FakePreparationService(), new RecordingNavigator());

await viewModel.RefreshAsync();

Assert.NotEqual(uiThreadId, catalog.ReadThreadId);
Assert.False(viewModel.IsLoading);
});
}

[Fact]
public async Task RefreshAsync_WhenCatalogFailsReportsInlineAndStopsLoading()
{
SnapshotEvent verified = NewEvent(SnapshotEventKind.Verified, @"C:\verified");
TimelineViewModel viewModel = new TimelineViewModel(
new FakeCatalog(verified), new FakePreparationService(), new RecordingNavigator());
await WpfTestHost.RunAsync(async () =>
{
TimelineViewModel viewModel = new TimelineViewModel(
new ThrowingCatalog(), new FakePreparationService(), new RecordingNavigator());

await viewModel.RefreshAsync();
viewModel.SelectedEvent = Assert.Single(viewModel.Events);
await viewModel.OpenSelectedAsync();
await viewModel.RefreshAsync();

Assert.True(viewModel.HasSelectionError);
Assert.Equal("unexpected preparation", viewModel.SelectionError);
Assert.False(viewModel.IsLoading);
Assert.Equal("Timeline could not be refreshed: catalog unavailable", viewModel.SelectionError);
});
}

[Theory]
Expand All @@ -75,6 +118,15 @@ public void SnapshotEventViewModel_MapsEveryKindToItsStableStatus(
Assert.Equal(isDiagnosticOnly, viewModel.Status.IsDiagnosticOnly);
}

[Fact]
public void SnapshotDiagnostic_UsesAReadableFallbackWhenNoReasonWasRecorded()
{
SnapshotEvent failed = NewEvent(SnapshotEventKind.Failed, @"C:\failed");

Assert.Equal("No additional diagnostic details were recorded for this snapshot.",
CompareDialogService.DiagnosticTextFor(failed));
}

private static SnapshotEvent NewEvent(SnapshotEventKind kind, string path, string reason = null)
=> new SnapshotEvent(kind, new DateTime(2026, 8, 9, 12, 0, 0, DateTimeKind.Local),
Path.GetFileName(path), Path.GetFullPath(path), reason, "TEST-PC", 0, true, null);
Expand All @@ -85,7 +137,19 @@ private sealed class FakeCatalog : ISnapshotEventReader

internal FakeCatalog(params SnapshotEvent[] events) => this.events = events;

public IReadOnlyList<SnapshotEvent> Read() => events;
internal int ReadThreadId { get; private set; }

public IReadOnlyList<SnapshotEvent> Read()
{
ReadThreadId = Thread.CurrentThread.ManagedThreadId;
return events;
}
}

private sealed class ThrowingCatalog : ISnapshotEventReader
{
public IReadOnlyList<SnapshotEvent> Read()
=> throw new InvalidOperationException("catalog unavailable");
}

private sealed class FakePreparationService : ISnapshotPayloadPreparationService
Expand Down
8 changes: 4 additions & 4 deletions src/WinRestoreKit.Tests/TimelineWpfSmokeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@ namespace WinRestoreKit.Tests
public sealed class TimelineWpfSmokeTests
{
[Fact]
public void TimelineView_LoadsSelectionAndTransfersPreparedSnapshot()
public async Task TimelineView_LoadsSelectionAndTransfersPreparedSnapshot()
{
WpfTestHost.Run(() =>
await WpfTestHost.RunAsync(async () =>
{
RecordingNavigator navigator = new RecordingNavigator();
SnapshotEvent snapshot = NewEvent(SnapshotEventKind.Verified, @"C:\timeline-smoke");
Expand All @@ -31,11 +31,11 @@ public void TimelineView_LoadsSelectionAndTransfersPreparedSnapshot()
host.Show();
try
{
viewModel.RefreshAsync().GetAwaiter().GetResult();
await viewModel.RefreshAsync();
ListBox list = FindDescendant<ListBox>(view);
Assert.NotNull(list);
list.SelectedIndex = 0;
viewModel.OpenSelectedAsync().GetAwaiter().GetResult();
await viewModel.OpenSelectedAsync();

Assert.NotNull(navigator.Prepared);
Assert.Equal(SnapshotEventKind.Verified, navigator.Prepared.Snapshot.Kind);
Expand Down
Loading
Loading