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
14 changes: 13 additions & 1 deletion 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 restore completion and release truth

- Aligned the WPF shell assembly and displayed product version with the canonical 0.0.1 release.
- Cleared snapshot validation as soon as invalid input is corrected and exposed the full validation,
severity, headline, and detail text to assistive technology.
- Made final restore consent resizable and scrollable so long paths wrap without clipping, and only
promised a pre-restore snapshot for modules that actually write settings.
- Routed completed restores to the result workspace and prevented the same confirmation page from
accidentally starting a second restore, while keeping long result summaries scrollable so the
Timeline action stays reachable.

### Fixed: restore and snapshot integrity

- Verified successful manifest rows against their physical artifacts and kept unavailable modules out
Expand All @@ -31,7 +42,8 @@ version numbers, because that is what those releases were called.
### Fixed: WPF lifecycle-safe navigation

- Added explicit Timeline and Advanced history destinations to the command bar so every read-only
snapshot workspace is reachable and users can always leave Compare.
snapshot workspace is reachable, and bound Escape to Timeline so keyboard users can always leave
Compare.
- Cancelled and cleared an active comparison before user-driven navigation, including when the same
snapshot is opened again later.
- Disabled workspace navigation while backup or restore work is running, and prevented the final WPF
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1152,9 +1152,10 @@ private async Task RunRestoreCore(IReadOnlyList<BackupBase> selection)
logger.LogMessage("Could not choose a snapshot folder name: " + ex.Message);
}

// Composing the plan reads four virtual members off every selected module -
// RestoreTargets, ProcessesToCloseBeforeRestore, Title and WarningMessage - and any of
// the four can throw from a module written later. This stage sits between the try above
// Composing the plan reads five virtual members off every selected module:
// RestoreTargets, ProcessesToCloseBeforeRestore, Title, WarningMessage, and
// RestoreMakesChanges. Any of them can throw from a module written later. This stage sits
// between the try above
// and the confirmation dialog, and the whole chain up to the async void click handler
// has no catch, so an escaping exception here would surface as WinForms' unhandled
// exception dialog mid-restore.
Expand Down
81 changes: 67 additions & 14 deletions src/WinRestoreKit.Core/Results/RestorePlan.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,15 @@ public sealed class RestorePlan
/// strongly in one place than in another. Both restore mechanisms are additive - regedit /s
/// merges, and CopyFolder leaves destination files absent from the source in place - so
/// offering "rollback" without this sentence would claim a guarantee the app does not hold.
///
/// Written with an escaped dash so the sentence survives being compiled from a source file
/// with no byte order mark.
/// </remarks>
public const string FidelityCaveat =
"The snapshot can put back settings this restore overwrites. It cannot remove registry " +
"values or files that this restore adds \u2014 restoring the snapshot merges it over the " +
"values or files that this restore adds; restoring the snapshot merges it over the " +
"current state rather than resetting to it.";

public const string NoSnapshotNotice =
"No pre-restore snapshot is created because the selected items do not write settings by themselves.";

public IReadOnlyList<BackupBase> Modules { get; }

/// <summary>The folder the restore reads from.</summary>
Expand All @@ -68,6 +68,12 @@ public sealed class RestorePlan
/// <summary>The module-by-module text: title, declared targets, and any warning.</summary>
public string ConfirmationText { get; }

/// <summary>Whether any selected module writes settings and therefore needs a safety snapshot.</summary>
public bool NeedsSnapshot { get; }

/// <summary>The exact safety-snapshot promise, or the reason no snapshot is created.</summary>
public string SnapshotNotice { get; }

/// <summary>One entry per distinct process that may only be closed with consent.</summary>
public IReadOnlyList<RestoreConsentEntry> ConsentEntries { get; }

Expand Down Expand Up @@ -99,7 +105,12 @@ public RestorePlan(IReadOnlyList<BackupBase> modules, string restoreSourcePath,

ConsentEntries = consent;
InformationalCloseLines = informational;
ConfirmationText = Compose(Modules, restoreSourcePath, snapshotDestination);
List<BackupBase> changeMaking = new List<BackupBase>();
List<BackupBase> interactive = new List<BackupBase>();
ClassifyModules(Modules, changeMaking, interactive);
NeedsSnapshot = changeMaking.Count > 0;
SnapshotNotice = NeedsSnapshot ? FidelityCaveat : NoSnapshotNotice;
ConfirmationText = Compose(changeMaking, interactive, restoreSourcePath, snapshotDestination);
}

private static IReadOnlyList<BackupBase> Compact(IReadOnlyList<BackupBase> modules)
Expand Down Expand Up @@ -144,17 +155,64 @@ private static void CollectCloses(IReadOnlyList<BackupBase> modules,
}
}

private static string Compose(IReadOnlyList<BackupBase> modules, string restoreSourcePath,
string snapshotDestination)
private static void ClassifyModules(IReadOnlyList<BackupBase> modules,
ICollection<BackupBase> changeMaking,
ICollection<BackupBase> interactive)
{
foreach (BackupBase module in modules)
{
if (module.RestoreMakesChanges)
changeMaking.Add(module);
else
interactive.Add(module);
}
}

private static string Compose(IReadOnlyList<BackupBase> changeMaking,
IReadOnlyList<BackupBase> interactive,
string restoreSourcePath, string snapshotDestination)
{
StringBuilder sb = new StringBuilder();

sb.AppendLine("Restoring from:");
sb.AppendLine(" " + restoreSourcePath);
sb.AppendLine();
sb.AppendLine("These items will be overwritten with what was backed up:");
sb.AppendLine();

if (changeMaking.Count > 0)
{
sb.AppendLine("These items will be overwritten with what was backed up:");
sb.AppendLine();
AppendModules(sb, changeMaking);
}

if (interactive.Count > 0)
{
sb.AppendLine("These items open an interactive restore step and do not write settings by themselves:");
sb.AppendLine();
AppendModules(sb, interactive);
}

if (changeMaking.Count == 0 && interactive.Count == 0)
{
sb.AppendLine("No restore items were selected.");
sb.AppendLine();
}

if (changeMaking.Count > 0)
{
sb.AppendLine("The current settings will first be copied to:");
sb.AppendLine(" " + snapshotDestination);
}
else
{
sb.AppendLine(NoSnapshotNotice);
}

return sb.ToString();
}

private static void AppendModules(StringBuilder sb, IReadOnlyList<BackupBase> modules)
{
foreach (BackupBase module in modules)
{
sb.AppendLine(module.Title);
Expand All @@ -167,11 +225,6 @@ private static string Compose(IReadOnlyList<BackupBase> modules, string restoreS

sb.AppendLine();
}

sb.AppendLine("The current settings will first be copied to:");
sb.AppendLine(" " + snapshotDestination);

return sb.ToString();
}

/// <remarks>
Expand Down
32 changes: 32 additions & 0 deletions src/WinRestoreKit.Tests/BackupWorkspaceViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,38 @@ public async Task StartAsync_BlankDestinationWithSelection_ShowsDestinationValid
Assert.Equal("Choose a destination folder before capturing.", vm.ValidationMessage);
}

[Fact]
public async Task Validation_ClearsAsSoonAsTheInvalidFieldOrSelectionIsCorrected()
{
var vm = new BackupWorkspaceViewModel(_ => Task.CompletedTask, string.Empty);
foreach (BackupScopeItemViewModel scope in vm.Scopes)
scope.IsSelected = false;

await vm.StartAsync();
Assert.NotNull(vm.ValidationMessage);

vm.Scopes.Single(scope => scope.Name == "Explorer & shell").IsSelected = true;
Assert.Null(vm.ValidationMessage);

await vm.StartAsync();
Assert.NotNull(vm.ValidationMessage);

vm.Destination = @"C:\snapshots";
Assert.Null(vm.ValidationMessage);
}

[Fact]
public async Task Validation_AutomationNameCarriesTheActualDiagnostic()
{
var vm = new BackupWorkspaceViewModel(_ => Task.CompletedTask, string.Empty);
foreach (BackupScopeItemViewModel scope in vm.Scopes)
scope.IsSelected = false;

await vm.StartAsync();

Assert.Equal("Snapshot validation: " + vm.ValidationMessage, vm.ValidationAutomationName);
}

[Fact]
public void UnfilteredEnvironmentScope_StartsUnselectedAndExposesItsCaution()
{
Expand Down
47 changes: 36 additions & 11 deletions src/WinRestoreKit.Tests/BackupWorkspaceViewTests.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
using System;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Automation;
using System.Windows.Controls;
using System.Windows.Threading;
using WinRestoreKit.Wpf.ViewModels;
using WinRestoreKit.Wpf.Views;
using Xunit;
Expand All @@ -14,21 +17,43 @@ public void View_ExposesScopeWarningsLabeledCompressionAndExactAutomationIds()
{
WpfTestHost.Run(() =>
{
var viewModel = new BackupWorkspaceViewModel(_ => Task.CompletedTask, @"C:\snapshots");
foreach (BackupScopeItemViewModel scope in viewModel.Scopes)
scope.IsSelected = false;
viewModel.StartAsync().GetAwaiter().GetResult();

var view = new BackupWorkspaceView
{
DataContext = new BackupWorkspaceViewModel(_ => Task.CompletedTask, @"C:\snapshots")
DataContext = viewModel
};
var host = new Window { Content = view, Width = 1024, Height = 720 };
host.Show();
host.UpdateLayout();
Dispatcher.CurrentDispatcher.Invoke(
DispatcherPriority.ApplicationIdle,
new Action(() => { }));

try
{
Assert.Equal("BackupWorkspace", AutomationProperties.GetAutomationId(view));
Assert.NotNull(view.FindName("BackupScopeList"));
Assert.NotNull(view.FindName("CreateSnapshotButton"));
Assert.NotNull(view.FindName("CompressionComboBox"));
Assert.Equal("BackupDestinationTextBox", AutomationProperties.GetAutomationId(
Assert.IsType<TextBox>(view.FindName("DestinationTextBox"))));
Assert.Equal("CreateSnapshotButton", AutomationProperties.GetAutomationId(
Assert.IsType<Button>(view.FindName("CreateSnapshotButton"))));
Assert.Equal("CompressionComboBox", AutomationProperties.GetAutomationId(
Assert.IsType<ComboBox>(view.FindName("CompressionComboBox"))));

Assert.Equal("BackupWorkspace", AutomationProperties.GetAutomationId(view));
Assert.NotNull(view.FindName("BackupScopeList"));
Assert.NotNull(view.FindName("CreateSnapshotButton"));
Assert.NotNull(view.FindName("CompressionComboBox"));
Assert.Equal("BackupDestinationTextBox", AutomationProperties.GetAutomationId(
Assert.IsType<TextBox>(view.FindName("DestinationTextBox"))));
Assert.Equal("CreateSnapshotButton", AutomationProperties.GetAutomationId(
Assert.IsType<Button>(view.FindName("CreateSnapshotButton"))));
Assert.Equal("CompressionComboBox", AutomationProperties.GetAutomationId(
Assert.IsType<ComboBox>(view.FindName("CompressionComboBox"))));
TextBlock validation = Assert.IsType<TextBlock>(view.FindName("BackupValidationText"));
Assert.Equal(viewModel.ValidationAutomationName, AutomationProperties.GetName(validation));
Assert.Equal(viewModel.ValidationMessage, AutomationProperties.GetHelpText(validation));
}
finally
{
host.Close();
}
});
}
}
Expand Down
45 changes: 43 additions & 2 deletions src/WinRestoreKit.Tests/ConfirmViewModelTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,22 @@ public void Confirm_GroupsOnlyExistingProcessAndExplorerImpacts()
Assert.Contains(viewModel.ConsentProcesses, item => item.DisplayName == "Visual Studio Code");
Assert.Contains(viewModel.ExplorerRestartModules, item => item.Title == "Taskbar");
Assert.Contains(viewModel.ModuleWarnings, item => item.Text == "Existing sign-out warning.");
Assert.Equal("2 selected modules will be evaluated for restore from this snapshot.",
viewModel.RestoreSelectionSummary);
});
}

[Fact]
public void Confirm_InteractiveOnlyModuleUsesSingularGrammarAndNoSnapshotNotice()
{
WpfTestHost.Run(() =>
{
ConfirmViewModel viewModel = new ConfirmViewModel(Snapshot(),
new BackupBase[] { new InteractiveModule("Remember installed apps") });

Assert.Equal("1 selected module will be evaluated for restore from this snapshot.",
viewModel.RestoreSelectionSummary);
Assert.Equal(RestorePlan.NoSnapshotNotice, viewModel.FidelityCaveat);
});
}

Expand All @@ -58,7 +74,18 @@ await WpfTestHost.RunAsync(async () =>
try
{
CancelingRunDialogService dialogs = new CancelingRunDialogService();
ConfirmViewModel viewModel = new ConfirmViewModel(Snapshot(SnapshotEventKind.Verified, source), new BackupBase[] { new TestModule("Module") });
int completions = 0;
bool callbackSawReleasedRun = false;
RunSummary completedSummary = null;
ConfirmViewModel viewModel = new ConfirmViewModel(
Snapshot(SnapshotEventKind.Verified, source),
new BackupBase[] { new TestModule("Module") },
showResult: (summary, _) =>
{
completions++;
completedSummary = summary;
callbackSawReleasedRun = !RunCoordinator.IsRunning;
});
Window owner = new Window();
viewModel.AttachRunSurfaces(Dispatcher.CurrentDispatcher, () => owner, dialogs);

Expand All @@ -67,7 +94,15 @@ await WpfTestHost.RunAsync(async () =>
Assert.Equal(Path.GetFullPath(source), dialogs.LastRestorePlan.RestoreSourcePath);
Assert.Single(dialogs.LastRestorePlan.Modules);
Assert.Contains("canceled", viewModel.Summary.Headline, StringComparison.OrdinalIgnoreCase);
Assert.Same(viewModel.Summary, completedSummary);
Assert.Equal(1, completions);
Assert.True(callbackSawReleasedRun);
Assert.True(viewModel.HasCompleted);
Assert.False(viewModel.CanStartRestore);
Assert.False(RunCoordinator.IsRunning);

await viewModel.StartRestoreAsync();
Assert.Equal(1, completions);
owner.Close();
}
finally
Expand All @@ -92,7 +127,7 @@ private sealed class CancelingRunDialogService : IRunDialogService
public void ShowPlanCompositionError(string text, string caption) { }
}

private sealed class TestModule : BackupBase
private class TestModule : BackupBase
{
internal TestModule(string title) => Title = title;
internal IReadOnlyList<RestoreCloseRequirement> Processes { get; set; } = Array.Empty<RestoreCloseRequirement>();
Expand All @@ -102,5 +137,11 @@ private sealed class TestModule : BackupBase
public override bool RequiresExplorerRestart => Explorer;
public override string WarningMessage => Warning;
}

private sealed class InteractiveModule : TestModule
{
internal InteractiveModule(string title) : base(title) { }
public override bool RestoreMakesChanges => false;
}
}
}
10 changes: 10 additions & 0 deletions src/WinRestoreKit.Tests/RebrandIdentityTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,5 +76,15 @@ public void ParseLatestVersion_OnRealAssemblyInfo_ReturnsPinnedFallbackVersion()
Assert.True(File.Exists(path), $"Expected AssemblyInfo test data at '{path}'.");
Assert.Equal("0.0.1", global::DataHelper.Data.ParseLatestVersion(File.ReadAllText(path)));
}

[Fact]
public void WpfShell_ReportsTheCanonicalProductVersion()
{
string path = Path.Combine(AppContext.BaseDirectory, "TestData", "AssemblyInfo.cs");
string canonical = global::DataHelper.Data.ParseLatestVersion(File.ReadAllText(path));

Assert.Equal(canonical,
global::WinRestoreKit.VersionInfo.GetCurrentVersion(typeof(global::WinRestoreKit.Wpf.App).Assembly));
}
}
}
Loading
Loading