From 80431cb0ea1f3972590aea5e7ce4b55742b656e0 Mon Sep 17 00:00:00 2001 From: Gaoyang Date: Tue, 13 Oct 2026 22:52:27 +0800 Subject: [PATCH] fix(app): keep the exit save overlay stable while disks save in parallel When several disks were saved in parallel on exit, every progress report overwrote the overlay with its own disk's name and byte counts, so the text and bar flickered between disks. The overlay now shows an aggregate. ExitSaveProgressTracker folds the per-disk reports into one thread-safe, size-weighted, monotonic state: a fixed byte total known up front, "Saving N disk images (k/N done)" for several disks, the disk's own name for one, and distinct texts for finishing and for failed saves. The save threads only update the tracker; the screen is refreshed through a coalescing signal at background priority so it cannot flood the dispatcher. MountManager.Dispose now reports just (disk, fraction), without the overall fraction, the byte count or the aggregator, and RamDisk.WillSaveOnDispose tells the app which disks will really be written. --- CLAUDE.md | 1 + src/ManagedDrive.App/App.xaml.cs | 57 ++- .../Localization/Strings.en-US.xaml | 3 + .../Localization/Strings.zh-CN.xaml | 3 + .../Services/CoalescingSignal.cs | 28 ++ .../Services/ExitSaveProgressTracker.cs | 220 +++++++++++ .../MainViewModel/MainViewModel.Status.cs | 103 ++++- .../Mounting/MountManager.cs | 89 +---- src/ManagedDrive.Core/Mounting/RamDisk.cs | 10 +- .../Mounting/RamDiskSaveDecisions.cs | 14 + .../ManagedDrive.Tests.csproj | 1 + .../Mounting/RamDiskSaveDecisionsTests.cs | 12 + .../Services/CoalescingSignalTests.cs | 71 ++++ .../Services/ExitSaveProgressTrackerTests.cs | 356 ++++++++++++++++++ .../DisposeProgressAggregatorTests.cs | 56 --- .../ViewModels/MainViewModelExitSaveTests.cs | 240 ++++++++++++ 16 files changed, 1118 insertions(+), 146 deletions(-) create mode 100644 src/ManagedDrive.App/Services/CoalescingSignal.cs create mode 100644 src/ManagedDrive.App/Services/ExitSaveProgressTracker.cs create mode 100644 tests/ManagedDrive.Tests/Services/CoalescingSignalTests.cs create mode 100644 tests/ManagedDrive.Tests/Services/ExitSaveProgressTrackerTests.cs delete mode 100644 tests/ManagedDrive.Tests/Utilities/DisposeProgressAggregatorTests.cs create mode 100644 tests/ManagedDrive.Tests/ViewModels/MainViewModelExitSaveTests.cs diff --git a/CLAUDE.md b/CLAUDE.md index 0f7ef0af..c001207b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,6 +80,7 @@ Integrity/hardening notes: a v6 segment's plaintext is verified against its inde - Disk presets: `DiskPreset`/`BuiltInPresets`/`PresetComposer`/`EnvRedirectPolicy` (`Core/DiskCreation`, pure) describe combinable templates; a disk card's context-menu "Presets" submenu (filled in `MainWindow.DiskContextMenu_Opened`) ticks/unticks presets live through `MainViewModel.GetActivePresetIds`/`SetPresetAsync`; the edit dialog ticks the presets a disk already has (`PresetSelection.Detect`), where for variable-redirecting presets the options are first reconciled with what the user's environment really points at (`MainViewModel.WithEnvironmentPresets` -> `PresetSelection.Reconcile`/`UserEnvironmentRedirector.PointsInto`) and ticking/unticking only changes folders and variables, keeping what no preset owns (`PresetSelection.Apply`); a live (no-remount) edit syncs the mounted disk via `DiskEffectsDiff` and `MainViewModel.SyncDiskEffectsAsync` (new variables applied, dropped ones restored, dropped folders left alone); a preset that redirects variables (`PresetSelection.IsExclusive`; not the folder-only browser one) lives on one disk only: creating or editing a disk to have it takes it from the others first (`MainViewModel.ReleaseClaimedPresetsAsync` -> `PresetSelection.Release`, variables restored, folders kept, unmounted profiles trimmed too), whereas auto-mount never takes over and still rejects a variable another disk owns; `DiskProfile` saves a disk's variable-redirecting presets as ids only (`DiskProfile.PresetIds`, via `PresetSelection.Split`/`Expand` in `ToProfile`/`ProfileToOptions`; older expanded profiles still load and are re-saved as ids), while the runtime `DiskOptions` keeps the expanded `Folders`/`EnvRedirects`; a disk keeps the result in `DiskOptions.Folders`/`EnvRedirects` (mirrored in `DiskProfile`, and `MainViewModel.ToProfile`/`ProfileToOptions` must carry any new field). A disk without an image is empty on every mount, so `MainViewModel.AddDiskSorted` runs `UserEnvironmentRedirector.ApplyDiskEffects` after each mount (creates the folders, points the per-user environment variables into the disk). The redirector stores each variable's previous registry value and kind in `EnvRedirectBackupStore` (`env-redirects.json` next to `settings.json`, so a damaged settings file cannot lose it) *before* writing, restores it when the disk leaves `Disks`, on exit (`RestoreAllEnvRedirects` in `ShutdownAsync`, and on Windows logoff/shutdown via `SessionEndingSaveHandler`'s `beforeSave` hook, which also resets TEMP and skips the broadcast) and for leftovers after a crash (`RestoreDanglingEnvRedirects` at startup, before the auto-mount); a variable is only restored while it still holds the value we wrote, and a variable already owned by another disk is refused. TEMP/TMP are ordinary redirects of the temp preset (`EnvRedirectPolicy` no longer reserves them), so they get the same backup/restore, and the context-menu "set as temp" action applies the same redirects; `MainViewModel.RestoreUserTemp` restores them from the backup and falls back to `TempDirResetService.Reset` (Windows defaults) only when nothing was recorded, e.g. a TEMP set by an older version. Ticking a temp-redirecting preset in the create dialog asks the one-time compatibility warning (`ConfirmTempDirWarning`). `IUserEnvironment` is the registry seam for tests. The `WM_SETTINGCHANGE` broadcast waits on every top-level window, so `TempDirResetService` sends it through `CoalescingBroadcaster` on a background thread (merged requests, `WaitForPendingBroadcasts` at exit) instead of on the caller's thread. Browsers have no cache environment variable, so that preset only creates a folder. The user-triggered restore (toolbar menu and tray submenu, `MainViewModel.GetEnvRestoreGroups`/`RestoreEnvAsync`) is a second path on the same backups: `EnvRestoreGroups` (Core, pure) groups the redirected variables by preset, `EnvRestoreService` restores one group (TEMP/TMP through `RestoreRecordedTemp`, which also covers a legacy TEMP without backup), and `DiskRestoreCoordinator` plus `DiskRestorePlanner` (Core, pure) make the mounted disks agree afterwards: before the restore it notes which presets/custom redirects point into each disk (`Plan`), afterwards it keeps only what really stopped pointing there (`Settle`, so a partly failed restore never leaves a disk claiming less than the environment backs) and drops it from the disk's options (`Apply`, same release as unticking the preset, folders on the disk stay). Call `DiskRestoreCoordinator.RunAsync`, not `Plan`/`TrimAsync` directly: it plans, runs the restore, trims even when the restore throws midway (some variables may already be back) and then rethrows (as an `AggregateException` with the report failure if the after-trim callback fails too). It runs on the UI thread under the busy overlay; `EnvRestoreMenuEntry` lays out the menu rows for both the window and the tray (`EnvRestoreTrayAction`). Keep these in step with the preset and backup rules above. - Read-only disks apply no presets and never warn about usage: `DiskOptions.AppliesPresets()`/`GetEffectiveHighUsageWarnPercent()` (and `DiskProfile.AppliesPresets()`) are the one rule every consumer reads (`ApplyDiskEffects`, `GetActivePresetIds`, the preset context menu, `SetPresetAsync`, the release of exclusive presets, `DiskViewModel`), and `DiskOptions.WithoutReadOnlyExtras` strips the entries where a disk is made read-only on purpose (`CreateDiskOptionsBuilder`, `MountOptionsFactory.BuildArchiveOptions`). It is deliberately *not* applied in `ProfileToOptions` or `MountOptionsFactory.BuildImageOptions`: a saved profile and a one-time CLI `--read-only` override keep their presets and warning so they are not written back stripped; do not "fix" that asymmetry. The edit dialog refuses to tick Read Only while a preset is ticked or TEMP/TMP is redirected (`CreateDiskDialog.ShouldRefuseReadOnly`), and warns that saving drops what the disk has. - Notifications: `DiskNotificationService` turns `DiskViewModel` events into status-bar text and tray balloons (balloons only while the main window is hidden). Besides high usage and save failures it reports refused writes: `MemoryFileSystem` raises `WriteRejected` (`WriteRejectionReason.LowMemory`/`DiskFull`, only on the refusal paths, at most once a second per reason) which `RamDisk` and `DiskViewModel` forward to the UI thread; the balloon is limited by `NotificationCooldown` (10 minutes per disk and reason, spent only when a balloon is actually shown). A 10 s poll feeds `LowMemoryMonitor` (pure; warns under 512 MB, recovers above 768 MB, 10-minute warning cooldown) so the user hears about low memory before the write guard starts refusing. `DiskViewModel.SaveRecovered` clears the sticky save-failure status once a save succeeds again. The disk card's redirect badge (`DiskViewModel.HasActiveRedirects`/`ActiveRedirectsTooltip`) shows what the environment really redirects into the disk, not only TEMP: `PresetSelection.DetectActive` (variable-redirecting presets whose variables all point into the disk, except that Temp also counts when TEMP alone does (`PresetSelection.TempPointsIntoDisk`, shared with `Reconcile`, which then syncs TMP too); plus custom variables) fed by `UserEnvVarCache` (5 s shared cache that bumps `Generation` only when a value changed or the app wrote the user environment) through `RedirectBadgeTracker`; all matching goes through `EnvRedirectPolicy.IsRedirectedInto`, also for `IsCurrentTempDir`, which stays TEMP-only and drives the unmount/reset logic. The status-bar file name is throttled to 500 ms (`DiskViewModel.ActivityThrottleWindow`). +- Exit save progress: `ShutdownAsync` calls `BeginExitSave` (sizes of the disks with `RamDisk.WillSaveOnDispose`, via `CollectExitSaveSizes`) *before* `MountManager.Dispose(Action)`, whose callback fires from the parallel save threads (concurrently, possibly out of order) with just the disk and its own fraction. The callback only feeds `ExitSaveProgressTracker` (thread-safe, size-weighted, monotonic, ignores disks it was not given; until `Complete` bar and bytes stay under 99% because a disk clean at collection time may still be saved untracked) and pokes a `CoalescingSignal`, which refreshes the overlay (`ShowExitSaveProgress`) at `Background` priority; the overlay shows the aggregate ("Saving N disk images (k/N done)", the disk's name only when there is one), never the last reporter's own name/bytes, which made it flicker. `SaveFailed` calls `MarkExitSaveFailed`, which only counts once that disk's own save has begun (the periodic auto-save raises `SaveFailed` too); a failed disk never shows as finished. After `Dispose`, `CoalescingSignal.Complete()` then `CompleteExitSave(markAllFinished)` run (the latter with `false` when `Dispose` threw). Do not show per-disk text from the callback or restore an overall value in `MountManager`. - Self-update: `UpdateCheckService` (check) -> `UpdateDialog` (prompt/progress) -> `UpdateInstaller` (download via GitHubReleaseUpdater into `%LOCALAPPDATA%\ManagedDrive\updates`, never `%TEMP%`, then start the Inno Setup installer with `/SILENT`). The app does not exit itself: `installer/ManagedDrive.iss` asks the running app to exit via `mdrive.exe exit` (saves disks) and restarts it afterwards. A TEMP on a RAM disk is reset first because the installer aborts silently in that case. Generic download/verify/launch logic lives in the separate GitHubReleaseUpdater library; keep only ManagedDrive-specific policy here. - `RamDisk.TryApplyOptions()` applies non-destructive changes live; drive-letter or read-only changes require full remount. - `CreateDiskDialog` has four modes: create, edit, import `.mdr`, import archive. Its `MainTabControl` has a fixed `MinHeight` (set to tallest tab) — bump if a tab's content grows. diff --git a/src/ManagedDrive.App/App.xaml.cs b/src/ManagedDrive.App/App.xaml.cs index bb1837a8..b3701e8b 100644 --- a/src/ManagedDrive.App/App.xaml.cs +++ b/src/ManagedDrive.App/App.xaml.cs @@ -876,7 +876,11 @@ private async Task ShutdownAsync() foreach (var disk in observedDisks) { var mountPoint = disk.Options.MountPoint; - disk.SaveFailed += (_, ex) => saveFailures.Enqueue(Loc.Format("Status.SaveFailed", mountPoint, ex.Message)); + disk.SaveFailed += (_, ex) => + { + saveFailures.Enqueue(Loc.Format("Status.SaveFailed", mountPoint, ex.Message)); + _mainViewModel?.MarkExitSaveFailed(mountPoint); + }; } TeardownBeforeMountManagerDispose(); @@ -888,11 +892,26 @@ private async Task ShutdownAsync() await _mainViewModel.WaitForPendingUnmountsAsync(); } + // Isolated: only the progress display depends on it, so a failure must not skip the save. + RunTeardownStep(() => _mainViewModel?.BeginExitSave(CollectExitSaveSizes())); + + // The disks report from their save threads, many times a second. The reports only update the + // view model's state; the screen is refreshed through a coalescing, below-input-priority + // delivery (like the startup splash) so they cannot flood the dispatcher or delay input. + var exitSaveRefresh = new CoalescingSignal( + () => _mainViewModel?.ShowExitSaveProgress(), + action => Dispatcher.BeginInvoke(DispatcherPriority.Background, action)); + var disposed = false; try { - await Task.Run(() => _mountManager?.Dispose((disk, diskFraction, overallFraction, totalBytes) => - Current.Dispatcher.BeginInvoke(() => - _mainViewModel?.ReportExitSaveProgress(disk.Options.MountPoint, overallFraction, diskFraction, totalBytes)))); + await Task.Run(() => _mountManager?.Dispose((disk, diskFraction) => + { + if (_mainViewModel?.ReportExitSaveProgress(disk.Options.MountPoint, diskFraction) == true) + { + exitSaveRefresh.Notify(); + } + })); + disposed = true; } catch (Exception ex) { @@ -901,6 +920,10 @@ await Task.Run(() => _mountManager?.Dispose((disk, diskFraction, overallFraction _logger.LogError(ex, "Disposing the mounted disks failed during shutdown."); } + // Shows the last value and drops deliveries still queued, then the final state. + exitSaveRefresh.Complete(); + RunTeardownStep(() => _mainViewModel?.CompleteExitSave(markAllFinished: disposed)); + if (!saveFailures.IsEmpty) { MessageBox.Show( @@ -918,6 +941,32 @@ await Task.Run(() => _mountManager?.Dispose((disk, diskFraction, overallFraction Shutdown(); } + /// + /// Sizes of the mounted disks whose final save will write an image, keyed by mount point, for + /// the exit progress. A disk that cannot be read (e.g. disposed by a concurrent unmount) is + /// left out and a repeated mount point is ignored, so this never throws. + /// + private Dictionary CollectExitSaveSizes() + { + var sizes = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var disk in _mountManager?.GetAll() ?? []) + { + try + { + if (disk.WillSaveOnDispose) + { + sizes.TryAdd(disk.Options.MountPoint, disk.UsedBytes); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not read a disk's size for the exit progress."); + } + } + + return sizes; + } + /// /// Common teardown shared by and , run /// before either one disposes (which they do differently: a diff --git a/src/ManagedDrive.App/Localization/Strings.en-US.xaml b/src/ManagedDrive.App/Localization/Strings.en-US.xaml index fa3adfa0..e6ee7a8d 100644 --- a/src/ManagedDrive.App/Localization/Strings.en-US.xaml +++ b/src/ManagedDrive.App/Localization/Strings.en-US.xaml @@ -127,6 +127,9 @@ Applying changes... Saving disk images, please wait... Saving "{0}"... + Saving {0} disk images ({1}/{0} done)... + Finishing up... + {0} of {1} disk images could not be saved Saving disk image... Creating snapshot... Loading disk {0} of {1}... diff --git a/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml b/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml index ece81a24..b469a64b 100644 --- a/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml +++ b/src/ManagedDrive.App/Localization/Strings.zh-CN.xaml @@ -127,6 +127,9 @@ 正在应用更改... 正在保存磁盘镜像,请稍候... 正在保存 "{0}"... + 正在保存 {0} 个磁盘镜像(已完成 {1}/{0})... + 正在完成... + {1} 个磁盘镜像中有 {0} 个保存失败 正在保存磁盘映像… 正在创建快照… 正在加载磁盘 {0}/{1}… diff --git a/src/ManagedDrive.App/Services/CoalescingSignal.cs b/src/ManagedDrive.App/Services/CoalescingSignal.cs new file mode 100644 index 00000000..9cb2fe45 --- /dev/null +++ b/src/ManagedDrive.App/Services/CoalescingSignal.cs @@ -0,0 +1,28 @@ +namespace ManagedDrive.App.Services; + +/// +/// Tells another thread (the UI thread) that "something changed, look again", merging signals that +/// arrive faster than it can act: while one refresh is still waiting, further signals add nothing. +/// For callers whose state lives elsewhere (read when the refresh runs); a value-carrying report +/// is . +/// +/// Runs on the thread chosen by , at most once per burst. +/// Schedules the refresh on that thread. +public sealed class CoalescingSignal(Action refresh, Action post) +{ + /// + /// The coalescer doing the work; the value it carries is not used. + /// + private readonly CoalescingProgress _inner = new(_ => refresh(), post); + + /// + /// Requests a refresh. Safe to call from any thread. + /// + public void Notify() => _inner.Report(0); + + /// + /// Runs the refresh now if one is still waiting, on the calling thread (which must be the one + /// the refresh expects), and ignores every later signal and any refresh still queued. + /// + public void Complete() => _inner.Complete(); +} diff --git a/src/ManagedDrive.App/Services/ExitSaveProgressTracker.cs b/src/ManagedDrive.App/Services/ExitSaveProgressTracker.cs new file mode 100644 index 00000000..c57f471b --- /dev/null +++ b/src/ManagedDrive.App/Services/ExitSaveProgressTracker.cs @@ -0,0 +1,220 @@ +namespace ManagedDrive.App.Services; + +/// +/// Folds the interleaved per-disk progress reports of the parallel exit save into one stable, +/// monotonic view, so the exit overlay does not flip between disks. Every disk and its size is +/// known up front, so the byte total is fixed and the overall fraction is weighted by size, which +/// keeps the bar and the byte counts in step. Thread-safe: the disks report from their save +/// threads, and the UI reads the newest state with . +/// +internal sealed class ExitSaveProgressTracker +{ + /// + /// The share of the work shown (bar and bytes) until : a disk that was + /// clean when the sizes were collected can still be written to and saved without being + /// tracked, so the other disks finishing must not show everything as done while it works. + /// + private const double UncompletedCeiling = 0.99; + + /// + /// One disk's state. Only touched under . + /// + /// Used bytes of the disk. + private sealed class DiskState(ulong totalBytes) + { + /// + /// Gets the used bytes of the disk. + /// + public ulong TotalBytes { get; } = totalBytes; + + /// + /// Gets or sets the furthest save fraction reached, in [0, 1]. + /// + public double Fraction { get; set; } + + /// + /// Gets or sets whether the disk's own final save has begun, i.e. it has reported. + /// + public bool Started { get; set; } + + /// + /// Gets or sets whether its save failed; it then stays where it was and never counts as finished. + /// + public bool Failed { get; set; } + + /// + /// Gets whether the disk is finished. + /// + public bool IsFinished => !Failed && Fraction >= 1.0; + } + + /// + /// Guards the state of every disk and the fields below. + /// + private readonly Lock _lock = new(); + + /// + /// The disks being written, by mount point. + /// + private readonly Dictionary _disks; + + /// + /// The highest overall fraction shown so far. + /// + private double _overall; + + /// + /// Whether was called. + /// + private bool _completed; + + /// + /// Creates a tracker for the disks about to be saved. + /// + /// Used bytes of every disk that will be written, keyed by mount point. + public ExitSaveProgressTracker(IReadOnlyDictionary totalBytesByMountPoint) + { + _disks = new(StringComparer.OrdinalIgnoreCase); + foreach (var (mountPoint, totalBytes) in totalBytesByMountPoint) + { + _disks[mountPoint] = new(totalBytes); + } + } + + /// + /// Gets how many disks are being written; zero when no disk had anything to save. + /// + public int DiskCount => _disks.Count; + + /// + /// A point-in-time view of the exit save. + /// + /// Size-weighted progress in [0, 1], never lower than an earlier one. + /// How many disks are being saved. + /// How many of them have finished. + /// Bytes written so far across all disks. + /// Used bytes of all disks. + /// The mount point of the only disk, or null when there are several. + /// + /// Whether every disk has finished (none failed) but the save is not over yet, because the + /// caller is still wrapping up (e.g. waiting for unmounts already in flight). + /// + /// How many disks' saves failed. + internal readonly record struct Snapshot( + double Overall, int DiskCount, int CompletedCount, ulong DoneBytes, ulong TotalBytes, string? MountPoint, + bool IsFinishing, int FailedCount) + { + /// + /// Gets whether every disk is either finished or failed, with at least one failure, so + /// nothing is left to wait for. + /// + public bool EndedWithFailures => FailedCount > 0 && CompletedCount + FailedCount == DiskCount; + } + + /// + /// Records one disk's progress. A disk that is not being tracked (it has nothing to write) or + /// whose save failed is ignored. The first report of a disk marks its own final save as begun. + /// + /// The reporting disk. + /// That disk's save progress in [0, 1]. + /// Whether the report changed anything the screen shows, i.e. a refresh is worthwhile. + public bool Report(string mountPoint, double diskFraction) + { + lock (_lock) + { + if (!_disks.TryGetValue(mountPoint, out var disk) || disk.Failed) + { + return false; + } + + var changed = !disk.Started; + disk.Started = true; + + // Reports from several threads can arrive out of order. + var fraction = Math.Clamp(diskFraction, 0.0, 1.0); + if (fraction > disk.Fraction) + { + disk.Fraction = fraction; + changed = true; + } + + return changed; + } + } + + /// + /// Records that a disk's save failed, so it is not shown as finished. Only counts once the + /// disk's own final save has begun (it has reported): SaveFailed is also raised by the + /// periodic auto-save, which keeps running on disks still waiting their turn, and such a failure + /// must not mark a disk whose final save then succeeds. + /// + /// The disk whose save failed. + public void MarkFailed(string mountPoint) + { + lock (_lock) + { + if (_disks.TryGetValue(mountPoint, out var disk) && disk.Started) + { + disk.Failed = true; + } + } + } + + /// + /// Marks every disk that did not fail as finished, for when the save returned without each one + /// reporting 1 (e.g. a disk that left the mount list in between). + /// + public void Complete() + { + lock (_lock) + { + _completed = true; + foreach (var disk in _disks.Values) + { + if (!disk.Failed) + { + disk.Fraction = 1.0; + } + } + } + } + + /// + /// Gets the aggregated state as of now, and advances the highest overall fraction shown so far + /// (so it is not a pure read: the fraction it returns never goes down). + /// + /// The current snapshot. + public Snapshot Sample() + { + lock (_lock) + { + ulong done = 0; + ulong total = 0; + double fractionSum = 0; + var completed = 0; + var failed = 0; + foreach (var disk in _disks.Values) + { + total += disk.TotalBytes; + done += (ulong)(disk.TotalBytes * disk.Fraction); + fractionSum += disk.Fraction; + completed += disk.IsFinished ? 1 : 0; + failed += disk.Failed ? 1 : 0; + } + + // Bar and bytes share the ceiling so they cannot disagree while the save is wrapping up. + // Rounded to the nearest byte so a tiny disk that is done does not read as unstarted. + var ceiling = _completed ? 1.0 : UncompletedCeiling; + done = Math.Min(done, (ulong)Math.Round(total * ceiling)); + + // Disks with nothing to write have no weight; fall back to counting them equally. + var overall = total > 0 ? (double)done / total : fractionSum / Math.Max(1, _disks.Count); + _overall = Math.Max(_overall, Math.Min(Math.Clamp(overall, 0.0, 1.0), ceiling)); + + return new(_overall, _disks.Count, completed, done, total, + _disks.Count == 1 ? _disks.Keys.Single() : null, + IsFinishing: _disks.Count > 0 && completed == _disks.Count, + FailedCount: failed); + } + } +} diff --git a/src/ManagedDrive.App/ViewModels/MainViewModel/MainViewModel.Status.cs b/src/ManagedDrive.App/ViewModels/MainViewModel/MainViewModel.Status.cs index 03f70930..45548195 100644 --- a/src/ManagedDrive.App/ViewModels/MainViewModel/MainViewModel.Status.cs +++ b/src/ManagedDrive.App/ViewModels/MainViewModel/MainViewModel.Status.cs @@ -50,22 +50,101 @@ internal void RefreshForTrayTooltip() } /// - /// Updates , , and - /// during the final on-exit save. Called from - /// App.ShutdownAsync via - /// . + /// The aggregate state of the exit save, written by the disks' save threads and read on the UI + /// thread. It is created on the UI thread by before the disks start. + /// + private ExitSaveProgressTracker? _exitSaveTracker; + + /// + /// Starts a fresh exit save covering the disks that will be written, so the total and the + /// weights are fixed before the first report arrives. + /// + /// Used bytes of every disk about to be written, keyed by mount point. + internal void BeginExitSave(IReadOnlyDictionary totalBytesByMountPoint) => + _exitSaveTracker = new(totalBytesByMountPoint); + + /// + /// Records one disk's save progress during the final on-exit save. Called from the disks' save + /// threads via + /// , + /// so it only updates the tracker; puts the result on screen. /// /// The mount point of the disk currently being saved. - /// Overall progress across all disks, in [0, 1]. - /// Save progress for the disk currently being saved, in [0, 1]. - /// The disk's total used bytes, for . - internal void ReportExitSaveProgress(string mountPoint, double overallFraction, double diskFraction, ulong totalBytes) + /// Save progress for that disk, in [0, 1]. + /// Whether the report changed what the overlay shows, i.e. a refresh is worthwhile. + internal bool ReportExitSaveProgress(string mountPoint, double diskFraction) => + _exitSaveTracker?.Report(mountPoint, diskFraction) ?? false; + + /// + /// Records that a disk's final save failed, so it is not shown as finished. Thread-safe. It only + /// counts once has run and that disk's own final save has begun (it + /// has reported), so a periodic auto-save failure does not mark a disk whose final save then + /// succeeds. + /// + /// The mount point of the disk whose save failed. + internal void MarkExitSaveFailed(string mountPoint) => _exitSaveTracker?.MarkFailed(mountPoint); + + /// + /// Updates , and + /// from the newest state. Disks are saved in parallel, so the + /// overlay shows the aggregate instead of whichever disk reported last (which made the text + /// jump). UI thread only. + /// + internal void ShowExitSaveProgress() + { + if (_exitSaveTracker is { DiskCount: > 0 } tracker) + { + ApplyExitSaveSnapshot(tracker.Sample()); + } + } + + /// + /// Shows the exit save as finished, once the disks have all been disposed. UI thread only. + /// + /// + /// false when disposing the disks threw, so disks that did not report are not claimed as + /// done. + /// + internal void CompleteExitSave(bool markAllFinished = true) + { + if (_exitSaveTracker is null) + { + return; + } + + if (_exitSaveTracker.DiskCount == 0) + { + // Nothing was written: keep the generic text, just fill the bar. + if (markAllFinished) + { + ExitSaveProgress = 1; + } + + return; + } + + if (markAllFinished) + { + _exitSaveTracker.Complete(); + } + + ApplyExitSaveSnapshot(_exitSaveTracker.Sample()); + } + + /// + /// Puts a tracker snapshot into the three exit-save properties. + /// + /// The snapshot to show. + private void ApplyExitSaveSnapshot(ExitSaveProgressTracker.Snapshot s) { - ExitSaveProgress = overallFraction; - ExitSaveStatusText = Loc.Format("Msg.ExitSavingDisk", mountPoint); + ExitSaveProgress = s.Overall; + ExitSaveStatusText = s.EndedWithFailures ? Loc.Format("Msg.ExitSavingFailed", s.FailedCount, s.DiskCount) + : s.IsFinishing ? Loc.Get("Msg.ExitSavingFinishing") + : s.MountPoint is { } single ? Loc.Format("Msg.ExitSavingDisk", single) + : Loc.Format("Msg.ExitSavingDisks", s.DiskCount, s.CompletedCount); ExitSaveDetailText = Loc.Format("Busy.ByteProgress", - ByteFormatter.Format((ulong)(totalBytes * Math.Clamp(diskFraction, 0.0, 1.0))), - ByteFormatter.Format(totalBytes)); + ByteFormatter.Format(s.DoneBytes), + ByteFormatter.Format(s.TotalBytes)); } /// diff --git a/src/ManagedDrive.Core/Mounting/MountManager.cs b/src/ManagedDrive.Core/Mounting/MountManager.cs index 142f2db9..32049ac2 100644 --- a/src/ManagedDrive.Core/Mounting/MountManager.cs +++ b/src/ManagedDrive.Core/Mounting/MountManager.cs @@ -25,13 +25,13 @@ public sealed class MountManager : IDisposable /// /// One task per call whose disk has already left but /// is still being disposed (and so possibly still saving). Guarded by . - /// waits for these, since it can + /// waits for these, since it can /// no longer see those disks and would otherwise let the process exit mid-save. /// private readonly List _unmountsInFlight = []; /// - /// Set under by . + /// Set under by . /// A still in flight when it's set disposes its own disk instead of /// registering it, since the disposal has already taken the list of disks to unmount. /// @@ -96,17 +96,15 @@ public MountManager() /// /// Unmounts and disposes all active disks, as , but reports save - /// progress via : the disk currently being saved, that disk's - /// own save fraction in [0, 1], the overall fraction across the whole disposal (also [0, 1], - /// averaged across all disks' own fractions), and that disk's total used bytes (captured - /// before disposal, since disk.UsedBytes can no longer be read afterwards — - /// disposes the underlying node map). Disks are saved - /// concurrently (bounded by half the processor count, since each disk's own save already - /// parallelizes its Zstd compression internally), so may be - /// invoked from multiple threads at once — it must be thread-safe. + /// progress via : the disk currently being saved and that disk's + /// own save fraction in [0, 1]. Disks are saved concurrently (bounded by half the processor + /// count, since each disk's own save already parallelizes its Zstd compression internally), so + /// may be invoked from multiple threads at once, and a value + /// reported later can be delivered earlier — it must be thread-safe and must not assume order + /// (keep the highest fraction seen per disk). It should return quickly. /// /// Optional progress callback. - public void Dispose(Action? onProgress) + public void Dispose(Action? onProgress) { _activityPollTimer.Dispose(); @@ -129,29 +127,12 @@ public void Dispose(Action? onProgress) return; } - // Captured once, before Dispose runs, since UsedBytes reads the node map that - // Dispose() tears down — reading it from a progress callback fired after disposal - // (e.g. the final 1.0 report below, or a delayed UI-thread dispatch of a mid-save - // tick) would throw ObjectDisposedException. - var totalBytesByDisk = new ulong[count]; - var aggregator = new DisposeProgressAggregator(count); - - for (var i = 0; i < count; i++) + foreach (var disk in all) { - totalBytesByDisk[i] = all[i].UsedBytes; - all[i].ContentAccessed -= OnDiskContentAccessed; + disk.ContentAccessed -= OnDiskContentAccessed; } - void ReportOverall(int diskIndex, double diskProgress) - { - if (onProgress is null) - { - return; - } - - var (reported, overall) = aggregator.Report(diskIndex, diskProgress); - onProgress(all[diskIndex], reported, overall, totalBytesByDisk[diskIndex]); - } + void ReportProgress(int diskIndex, double diskProgress) => onProgress?.Invoke(all[diskIndex], diskProgress); // Each disk's own save already parallelizes its Zstd compression internally, so disks // are only saved a few at a time (not fully unbounded) to avoid oversubscribing the CPU. @@ -166,7 +147,7 @@ void ReportOverall(int diskIndex, double diskProgress) { try { - ReportOverall(i, 0.0); + ReportProgress(i, 0.0); } catch (Exception ex) { @@ -177,7 +158,7 @@ void ReportOverall(int diskIndex, double diskProgress) { // Reported synchronously: Progress would post each report to the thread pool, so a // late mid-save report could land after the final 1.0 below and drag progress back. - var perDiskProgress = onProgress is null ? null : new SynchronousProgress(p => ReportOverall(i, p)); + var perDiskProgress = onProgress is null ? null : new SynchronousProgress(p => ReportProgress(i, p)); all[i].Dispose(perDiskProgress); } catch (Exception ex) @@ -188,7 +169,7 @@ void ReportOverall(int diskIndex, double diskProgress) try { - ReportOverall(i, 1.0); + ReportProgress(i, 1.0); } catch (Exception ex) { @@ -206,7 +187,7 @@ void ReportOverall(int diskIndex, double diskProgress) /// /// Blocks until every that was still disposing its disk when - /// started has finished, so the + /// started has finished, so the /// final saves of those disks are not cut short by process exit. The unmounts report their own /// failures to their callers, so only completion is awaited here. /// @@ -437,41 +418,3 @@ private sealed class SynchronousProgress(Action onReport) : IProgress onReport(value); } } - -/// -/// Combines the save progress of disks disposed concurrently into an overall fraction. Each -/// disk's progress only moves forward, so the overall fraction never goes back either, even if -/// reports arrive out of order. -/// -/// Number of disks being disposed; must be positive. -internal sealed class DisposeProgressAggregator(int diskCount) -{ - /// - /// Furthest progress reported so far for each disk, in [0, 1]. - /// - private readonly double[] _progressByDisk = new double[diskCount]; - - /// - /// Guards against concurrent reports. - /// - private readonly Lock _lock = new(); - - /// - /// Records a progress report for one disk. - /// - /// Index of the reporting disk. - /// The disk's reported progress. - /// - /// The disk's progress to show (never lower than an earlier report) and the average across - /// all disks. - /// - public (double DiskProgress, double Overall) Report(int diskIndex, double diskProgress) - { - lock (_lock) - { - var progress = Math.Max(_progressByDisk[diskIndex], Math.Clamp(diskProgress, 0.0, 1.0)); - _progressByDisk[diskIndex] = progress; - return (progress, _progressByDisk.Sum() / _progressByDisk.Length); - } - } -} diff --git a/src/ManagedDrive.Core/Mounting/RamDisk.cs b/src/ManagedDrive.Core/Mounting/RamDisk.cs index 42b9a693..2d027054 100644 --- a/src/ManagedDrive.Core/Mounting/RamDisk.cs +++ b/src/ManagedDrive.Core/Mounting/RamDisk.cs @@ -650,7 +650,7 @@ public void Dispose(IProgress? progress) /// Optional progress reporter, updated with a fraction in [0, 1]. private void SaveOnDispose(IProgress? progress) { - if (Options.PersistImagePath == null || _skipFinalSave) + if (!RamDiskSaveDecisions.HasFinalSaveTarget(Options.PersistImagePath, _skipFinalSave)) { return; } @@ -1472,6 +1472,14 @@ private bool IsUnchangedSinceLatestSnapshot(string mainImagePath) => /// private bool NeedsExitSave() => RamDiskSaveDecisions.NeedsExitSave(Options.SaveImageOnExit, NeedsSave()); + /// + /// Gets whether disposing this disk right now would write its image (see + /// ). A snapshot for progress display; the + /// answer can change if the disk is modified later. + /// + public bool WillSaveOnDispose => + RamDiskSaveDecisions.WillSaveOnDispose(Options.PersistImagePath, _skipFinalSave, Options.SaveImageOnExit, NeedsSave()); + /// /// Whether a save would actually write anything. Shared by and /// so both skip saving under the same condition. See diff --git a/src/ManagedDrive.Core/Mounting/RamDiskSaveDecisions.cs b/src/ManagedDrive.Core/Mounting/RamDiskSaveDecisions.cs index 0bb98e52..caa1a5ad 100644 --- a/src/ManagedDrive.Core/Mounting/RamDiskSaveDecisions.cs +++ b/src/ManagedDrive.Core/Mounting/RamDiskSaveDecisions.cs @@ -21,6 +21,20 @@ internal static bool NeedsSave(bool isDirty, string? configuredPersistImagePath, /// internal static bool NeedsExitSave(bool saveImageOnExit, bool needsSave) => saveImageOnExit && needsSave; + /// + /// Whether disposing the disk may write an image at all: a persist path is configured and the + /// final save was not switched off. + /// + internal static bool HasFinalSaveTarget(string? persistImagePath, bool skipFinalSave) => + persistImagePath != null && !skipFinalSave; + + /// + /// Whether disposing the disk now would write its image: and + /// both hold. This is what RamDisk.SaveOnDispose decides. + /// + internal static bool WillSaveOnDispose(string? persistImagePath, bool skipFinalSave, bool saveImageOnExit, bool needsSave) => + HasFinalSaveTarget(persistImagePath, skipFinalSave) && NeedsExitSave(saveImageOnExit, needsSave); + /// /// Whether should generate a fresh content-encryption key /// rather than reuse to just re-wrap it under a new password. diff --git a/tests/ManagedDrive.Tests/ManagedDrive.Tests.csproj b/tests/ManagedDrive.Tests/ManagedDrive.Tests.csproj index 1c965caf..318c05ab 100644 --- a/tests/ManagedDrive.Tests/ManagedDrive.Tests.csproj +++ b/tests/ManagedDrive.Tests/ManagedDrive.Tests.csproj @@ -33,6 +33,7 @@ + diff --git a/tests/ManagedDrive.Tests/Mounting/RamDiskSaveDecisionsTests.cs b/tests/ManagedDrive.Tests/Mounting/RamDiskSaveDecisionsTests.cs index 8a6ced99..f08baaed 100644 --- a/tests/ManagedDrive.Tests/Mounting/RamDiskSaveDecisionsTests.cs +++ b/tests/ManagedDrive.Tests/Mounting/RamDiskSaveDecisionsTests.cs @@ -23,6 +23,18 @@ public void Dispose() } } + [Theory] + [InlineData("a.mdr", false, true, true, true)] + [InlineData(null, false, true, true, false)] + [InlineData("a.mdr", true, true, true, false)] + [InlineData("a.mdr", false, false, true, false)] + [InlineData("a.mdr", false, true, false, false)] + public void WillSaveOnDispose_RequiresTargetNotSkippedExitSaveAndChanges( + string? path, bool skip, bool saveOnExit, bool needsSave, bool expected) + { + Assert.Equal(expected, RamDiskSaveDecisions.WillSaveOnDispose(path, skip, saveOnExit, needsSave)); + } + [Fact] public void NeedsSave_DirtyFileSystem_ReturnsTrue() { diff --git a/tests/ManagedDrive.Tests/Services/CoalescingSignalTests.cs b/tests/ManagedDrive.Tests/Services/CoalescingSignalTests.cs new file mode 100644 index 00000000..3e3f43d0 --- /dev/null +++ b/tests/ManagedDrive.Tests/Services/CoalescingSignalTests.cs @@ -0,0 +1,71 @@ +using ManagedDrive.App.Services; + +namespace ManagedDrive.Tests; + +/// +/// Tests , which merges bursts of "changed" signals into one refresh. +/// +public sealed class CoalescingSignalTests +{ + /// + /// Scheduled refreshes that have not run yet, standing in for the dispatcher queue. + /// + private readonly Queue _queue = new(); + + /// + /// How many times the refresh ran. + /// + private int _refreshes; + + /// + /// Creates the signal under test, wired to the fake queue. + /// + private CoalescingSignal Create() => new(() => _refreshes++, _queue.Enqueue); + + /// + /// A burst of signals schedules one refresh. + /// + [Fact] + public void Notify_Burst_RefreshesOnce() + { + var signal = Create(); + + signal.Notify(); + signal.Notify(); + signal.Notify(); + _queue.Dequeue()(); + + Assert.Empty(_queue); + Assert.Equal(1, _refreshes); + } + + /// + /// A signal after the refresh ran schedules the next one. + /// + [Fact] + public void Notify_AfterRefresh_SchedulesAnother() + { + var signal = Create(); + signal.Notify(); + _queue.Dequeue()(); + + signal.Notify(); + + Assert.Single(_queue); + } + + /// + /// Completing runs a waiting refresh now and drops the queued one. + /// + [Fact] + public void Complete_WithPendingSignal_RefreshesNowAndDropsTheQueuedOne() + { + var signal = Create(); + signal.Notify(); + + signal.Complete(); + _queue.Dequeue()(); + + Assert.Equal(1, _refreshes); + } +} diff --git a/tests/ManagedDrive.Tests/Services/ExitSaveProgressTrackerTests.cs b/tests/ManagedDrive.Tests/Services/ExitSaveProgressTrackerTests.cs new file mode 100644 index 00000000..80d81aa2 --- /dev/null +++ b/tests/ManagedDrive.Tests/Services/ExitSaveProgressTrackerTests.cs @@ -0,0 +1,356 @@ +using ManagedDrive.App.Services; + +namespace ManagedDrive.Tests; + +/// +/// Tests , which keeps the exit overlay stable while +/// several disks report progress in parallel. +/// +public sealed class ExitSaveProgressTrackerTests +{ + /// + /// Creates a tracker for disks with the given sizes. + /// + /// Mount point and used bytes of each disk. + /// The tracker. + private static ExitSaveProgressTracker Create(params (string MountPoint, ulong Bytes)[] disks) => + new(disks.ToDictionary(d => d.MountPoint, d => d.Bytes)); + + /// + /// Reports a disk's progress and returns the resulting state. + /// + private static ExitSaveProgressTracker.Snapshot Report(ExitSaveProgressTracker tracker, string mountPoint, double fraction) + { + tracker.Report(mountPoint, fraction); + return tracker.Sample(); + } + + /// + /// A single disk keeps its own name in the status text. + /// + [Fact] + public void SingleDisk_ReportsItsMountPoint() + { + var tracker = Create(("R:", 1000)); + + var s = Report(tracker, "R:", 0.5); + + Assert.Equal("R:", s.MountPoint); + Assert.Equal(500UL, s.DoneBytes); + } + + /// + /// With several disks no single mount point is shown, however the reports interleave. + /// + [Fact] + public void MultipleDisks_DoNotNameOneDisk() + { + var tracker = Create(("R:", 1000), ("S:", 4000)); + + Assert.Null(Report(tracker, "R:", 0.5).MountPoint); + Assert.Null(Report(tracker, "S:", 0.5).MountPoint); + Assert.Null(Report(tracker, "R:", 0.6).MountPoint); + } + + /// + /// The byte total covers every disk from the first report, including disks not started yet. + /// + [Fact] + public void TotalBytes_IsFixedFromTheFirstReport() + { + var tracker = Create(("R:", 1000), ("S:", 4000), ("T:", 5000)); + + var first = Report(tracker, "R:", 0.5); + var later = Report(tracker, "S:", 0.5); + + Assert.Equal(10000UL, first.TotalBytes); + Assert.Equal(10000UL, later.TotalBytes); + } + + /// + /// Overall progress is weighted by size, so a small finished disk barely moves it. + /// + [Fact] + public void Overall_IsWeightedBySize() + { + var tracker = Create(("R:", 10), ("S:", 990)); + + var s = Report(tracker, "R:", 1.0); + + Assert.Equal(0.01, s.Overall, 3); + } + + /// + /// Overall progress and bytes written never go back when reports arrive out of order. + /// + [Fact] + public void OutOfOrderReports_NeverMoveBackwards() + { + var tracker = Create(("R:", 1000), ("S:", 1000)); + var first = Report(tracker, "R:", 0.8); + + var late = Report(tracker, "R:", 0.4); + + Assert.Equal(first.Overall, late.Overall); + Assert.Equal(first.DoneBytes, late.DoneBytes); + } + + /// + /// Finished disks are counted. + /// + [Fact] + public void CompletedDisks_AreCounted() + { + var tracker = Create(("R:", 100), ("S:", 100), ("T:", 100)); + tracker.Report("R:", 1.0); + tracker.Report("S:", 0.5); + + var s = Report(tracker, "T:", 1.0); + + Assert.Equal(2, s.CompletedCount); + Assert.Equal(3, s.DiskCount); + } + + /// + /// Disks with nothing to write count equally towards overall progress. + /// + [Fact] + public void EmptyDisks_CountEquallyTowardsOverall() + { + var tracker = Create(("R:", 0), ("S:", 0)); + + var s = Report(tracker, "R:", 1.0); + + Assert.Equal(0.5, s.Overall, 3); + } + + /// + /// Completing fills the bar even when the disks have no bytes to weigh. + /// + [Fact] + public void EmptyDisks_CompleteFillsTheBar() + { + var tracker = Create(("R:", 0), ("S:", 0)); + + tracker.Complete(); + + Assert.Equal(1.0, tracker.Sample().Overall); + } + + /// + /// Once every disk is done but the save is not completed the state is 'finishing', never with a failure. + /// + [Fact] + public void AllDisksDone_IsFinishingUnlessOneFailed() + { + var tracker = Create(("R:", 100), ("S:", 100)); + Assert.False(Report(tracker, "R:", 1.0).IsFinishing); + Assert.True(Report(tracker, "S:", 1.0).IsFinishing); + + var failed = Create(("R:", 100), ("S:", 100)); + failed.Report("R:", 0.5); + failed.MarkFailed("R:"); + failed.Report("S:", 1.0); + Assert.False(failed.Sample().IsFinishing); + } + + /// + /// A disk that is not being tracked is ignored and leaves the totals alone. + /// + [Fact] + public void UnknownDisk_IsIgnored() + { + var tracker = Create(("R:", 1000)); + + var s = Report(tracker, "S:", 1.0); + + Assert.Equal(1000UL, s.TotalBytes); + Assert.Equal(0UL, s.DoneBytes); + Assert.Equal(1, s.DiskCount); + } + + /// + /// Completing marks every disk finished even if it never reported 1. + /// + [Fact] + public void Complete_MarksEveryDiskFinished() + { + var tracker = Create(("R:", 1000), ("S:", 3000)); + tracker.Report("R:", 0.5); + + tracker.Complete(); + var s = tracker.Sample(); + + Assert.Equal(1.0, s.Overall); + Assert.Equal(2, s.CompletedCount); + Assert.Equal(4000UL, s.DoneBytes); + } + + /// + /// The bar stays below full until completion, since an untracked disk may still be saving. + /// + [Fact] + public void BeforeComplete_OverallStaysBelowFull() + { + var tracker = Create(("R:", 1000)); + + var s = Report(tracker, "R:", 1.0); + + Assert.True(s.Overall < 1.0); + } + + /// + /// A disk whose save failed is not shown as finished, even if it reports 1 afterwards or the + /// rest is completed. + /// + [Fact] + public void FailedDisk_IsNeverShownAsFinished() + { + var tracker = Create(("R:", 1000), ("S:", 1000)); + tracker.Report("R:", 0.4); + tracker.MarkFailed("R:"); + tracker.Report("R:", 1.0); + tracker.Report("S:", 1.0); + + tracker.Complete(); + var s = tracker.Sample(); + + Assert.Equal(1, s.CompletedCount); + Assert.True(s.Overall < 1.0); + } + + /// + /// Mount points are matched without regard to case, and two spellings of one disk collapse. + /// + [Fact] + public void MountPoints_AreMatchedIgnoringCase() + { + var tracker = new ExitSaveProgressTracker(new Dictionary { ["R:"] = 1000, ["r:"] = 1000 }); + + var s = Report(tracker, "r:", 0.5); + + Assert.Equal(1, s.DiskCount); + Assert.Equal(500UL, s.DoneBytes); + } + + /// + /// Reports racing in from several threads never lose a value or throw. + /// + [Fact] + public void ConcurrentReports_EndAtTheFinalState() + { + var disks = Enumerable.Range(0, 8).Select(i => ((string)$"D{i}:", 1000UL)).ToArray(); + var tracker = Create(disks); + + Parallel.ForEach(disks, d => + { + for (var step = 0; step <= 100; step++) + { + tracker.Report(d.Item1, step / 100.0); + tracker.Sample(); + } + }); + Assert.Equal(8, tracker.Sample().CompletedCount); + tracker.Complete(); + + Assert.Equal(8000UL, tracker.Sample().DoneBytes); + } + + /// + /// Out-of-range fractions are clamped into [0, 1], so bytes never exceed the total (nor the + /// 99% ceiling that applies until completion). + /// + /// The out-of-range fraction reported. + /// The bytes it counts as. + [Theory] + [InlineData(-0.5, 0UL)] + [InlineData(1.5, 990UL)] + public void OutOfRangeFraction_IsClamped(double reported, ulong expectedBytes) + { + var tracker = Create(("R:", 1000)); + + var s = Report(tracker, "R:", reported); + + Assert.Equal(expectedBytes, s.DoneBytes); + } + + /// + /// A report only asks for a refresh when it changes something: the first report of a disk or a + /// higher fraction does; an unknown disk, a repeat, a lower fraction or a failed disk does not. + /// + [Fact] + public void Report_ReturnsWhetherAnythingChanged() + { + var tracker = Create(("R:", 1000), ("S:", 1000)); + + Assert.True(tracker.Report("R:", 0.0)); + Assert.False(tracker.Report("R:", 0.0)); + Assert.True(tracker.Report("R:", 0.5)); + Assert.False(tracker.Report("R:", 0.3)); + Assert.False(tracker.Report("X:", 0.5)); + tracker.Report("S:", 0.1); + tracker.MarkFailed("S:"); + Assert.False(tracker.Report("S:", 1.0)); + } + + /// + /// A tiny finished disk does not read as unstarted because of the ceiling's rounding. + /// + [Fact] + public void TinyDisk_WhenDone_ShowsItsBytes() + { + var tracker = Create(("R:", 1)); + + var s = Report(tracker, "R:", 1.0); + + Assert.Equal(1UL, s.DoneBytes); + } + + /// + /// The byte count shares the ceiling with the bar until completion, so they cannot disagree. + /// + [Fact] + public void BeforeComplete_BytesStayBelowTheTotalLikeTheBar() + { + var tracker = Create(("R:", 1000)); + + var s = Report(tracker, "R:", 1.0); + + Assert.True(s.DoneBytes < s.TotalBytes); + Assert.Equal(s.Overall, (double)s.DoneBytes / s.TotalBytes, 3); + } + + /// + /// A failure reported before the disk's own save has begun (an auto-save) is ignored. + /// + [Fact] + public void FailureBeforeTheDiskStarted_IsIgnored() + { + var tracker = Create(("R:", 1000)); + tracker.MarkFailed("R:"); + + Report(tracker, "R:", 1.0); + tracker.Complete(); + var s = tracker.Sample(); + + Assert.Equal(0, s.FailedCount); + Assert.Equal(1, s.CompletedCount); + } + + /// + /// Once every disk is finished or failed, with a failure, the save has ended with failures. + /// + [Fact] + public void EndedWithFailures_WhenEveryDiskIsFinishedOrFailed() + { + var tracker = Create(("R:", 100), ("S:", 100)); + tracker.Report("R:", 0.3); + tracker.MarkFailed("R:"); + Assert.False(tracker.Sample().EndedWithFailures); + + var s = Report(tracker, "S:", 1.0); + + Assert.True(s.EndedWithFailures); + Assert.Equal(1, s.FailedCount); + } +} diff --git a/tests/ManagedDrive.Tests/Utilities/DisposeProgressAggregatorTests.cs b/tests/ManagedDrive.Tests/Utilities/DisposeProgressAggregatorTests.cs deleted file mode 100644 index 3e4606e2..00000000 --- a/tests/ManagedDrive.Tests/Utilities/DisposeProgressAggregatorTests.cs +++ /dev/null @@ -1,56 +0,0 @@ -namespace ManagedDrive.Tests; - -/// -/// Tests for how combines per-disk save progress. -/// -public sealed class DisposeProgressAggregatorTests -{ - /// - /// The overall fraction is the average of every disk's progress. - /// - [Fact] - public void Report_SeveralDisks_ReturnsAverageAsOverall() - { - var aggregator = new DisposeProgressAggregator(2); - - aggregator.Report(0, 1.0); - var (disk, overall) = aggregator.Report(1, 0.5); - - Assert.Equal(0.5, disk); - Assert.Equal(0.75, overall); - } - - /// - /// A report arriving after a later one (e.g. a mid-save tick after the final 1.0) doesn't - /// move that disk's progress, or the overall fraction, backwards. - /// - [Fact] - public void Report_LowerThanEarlierReport_KeepsEarlierProgress() - { - var aggregator = new DisposeProgressAggregator(2); - aggregator.Report(0, 1.0); - - var (disk, overall) = aggregator.Report(0, 0.3); - - Assert.Equal(1.0, disk); - Assert.Equal(0.5, overall); - } - - /// - /// Values outside [0, 1] are clamped into it. - /// - /// The out-of-range value reported. - /// The clamped progress. - [Theory] - [InlineData(-0.5, 0.0)] - [InlineData(1.5, 1.0)] - public void Report_OutOfRange_ClampsToUnitInterval(double reported, double expected) - { - var aggregator = new DisposeProgressAggregator(1); - - var (disk, overall) = aggregator.Report(0, reported); - - Assert.Equal(expected, disk); - Assert.Equal(expected, overall); - } -} diff --git a/tests/ManagedDrive.Tests/ViewModels/MainViewModelExitSaveTests.cs b/tests/ManagedDrive.Tests/ViewModels/MainViewModelExitSaveTests.cs new file mode 100644 index 00000000..5d0ff0c8 --- /dev/null +++ b/tests/ManagedDrive.Tests/ViewModels/MainViewModelExitSaveTests.cs @@ -0,0 +1,240 @@ +using ManagedDrive.App.Services; +using ManagedDrive.App.ViewModels; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ManagedDrive.Tests; + +/// +/// Tests how turns the parallel exit-save reports into overlay text. +/// Without an Application the localized strings resolve to their keys, so the keys are compared. +/// +public sealed class MainViewModelExitSaveTests : IDisposable +{ + /// + /// Per-test temporary directory holding the settings file. + /// + private readonly string _dir = Path.Combine(Path.GetTempPath(), "ManagedDrive.Tests." + Guid.NewGuid()); + + /// + /// Removes the temporary directory. + /// + public void Dispose() + { + try + { + Directory.Delete(_dir, recursive: true); + } + catch + { + // Best-effort cleanup. + } + } + + /// + /// Several disks are summarised, not named, and the text does not change with the reporting disk. + /// + [Fact] + public void MultipleDisks_ShowTheSummaryTextWhicheverDiskReports() + { + using var mountManager = new MountManager(); + using var viewModel = CreateViewModel(mountManager); + viewModel.BeginExitSave(new Dictionary { ["R:"] = 1000, ["S:"] = 3000 }); + + viewModel.ReportExitSaveProgress("R:", 0.5); + viewModel.ShowExitSaveProgress(); + var afterFirst = viewModel.ExitSaveStatusText; + viewModel.ReportExitSaveProgress("S:", 0.5); + viewModel.ShowExitSaveProgress(); + + Assert.Equal("Msg.ExitSavingDisks", afterFirst); + Assert.Equal(afterFirst, viewModel.ExitSaveStatusText); + } + + /// + /// One disk keeps its own name. + /// + [Fact] + public void SingleDisk_ShowsItsName() + { + using var mountManager = new MountManager(); + using var viewModel = CreateViewModel(mountManager); + viewModel.BeginExitSave(new Dictionary { ["R:"] = 1000 }); + + viewModel.ReportExitSaveProgress("R:", 0.5); + viewModel.ShowExitSaveProgress(); + + Assert.Equal("Msg.ExitSavingDisk", viewModel.ExitSaveStatusText); + Assert.Equal(0.5, viewModel.ExitSaveProgress, 3); + } + + /// + /// A report for a disk that is not being written, or before the save began, changes nothing. + /// + [Fact] + public void UnknownDiskOrNoSave_IsIgnored() + { + using var mountManager = new MountManager(); + using var viewModel = CreateViewModel(mountManager); + + viewModel.ReportExitSaveProgress("R:", 0.5); + viewModel.ShowExitSaveProgress(); + viewModel.BeginExitSave(new Dictionary { ["R:"] = 1000 }); + viewModel.ReportExitSaveProgress("S:", 0.5); + viewModel.ShowExitSaveProgress(); + + Assert.Equal(0.0, viewModel.ExitSaveProgress); + } + + /// + /// Completing the save fills the bar even if a disk never reported. + /// + [Fact] + public void CompleteExitSave_FillsTheBar() + { + using var mountManager = new MountManager(); + using var viewModel = CreateViewModel(mountManager); + viewModel.BeginExitSave(new Dictionary { ["R:"] = 1000, ["S:"] = 1000 }); + viewModel.ReportExitSaveProgress("R:", 1.0); + viewModel.ShowExitSaveProgress(); + + viewModel.CompleteExitSave(); + + Assert.Equal(1.0, viewModel.ExitSaveProgress); + } + + /// + /// A disk whose save failed is not claimed as done, and neither is anything when disposing threw. + /// + [Fact] + public void CompleteExitSave_DoesNotClaimFailures() + { + using var mountManager = new MountManager(); + using var viewModel = CreateViewModel(mountManager); + viewModel.BeginExitSave(new Dictionary { ["R:"] = 1000, ["S:"] = 1000 }); + viewModel.ReportExitSaveProgress("R:", 0.4); + viewModel.MarkExitSaveFailed("R:"); + viewModel.ReportExitSaveProgress("R:", 1.0); + + viewModel.CompleteExitSave(); + + Assert.True(viewModel.ExitSaveProgress < 1.0); + + viewModel.BeginExitSave(new Dictionary { ["R:"] = 1000 }); + viewModel.CompleteExitSave(markAllFinished: false); + + Assert.True(viewModel.ExitSaveProgress < 1.0); + } + + /// + /// A failure reported before the final saves began (e.g. a periodic auto-save) does not mark + /// the disk, so a final save that then succeeds is shown as finished. + /// + [Fact] + public void FailureBeforeBeginExitSave_IsIgnored() + { + using var mountManager = new MountManager(); + using var viewModel = CreateViewModel(mountManager); + viewModel.MarkExitSaveFailed("R:"); + viewModel.BeginExitSave(new Dictionary { ["R:"] = 1000 }); + + viewModel.ReportExitSaveProgress("R:", 1.0); + viewModel.CompleteExitSave(); + + Assert.Equal(1.0, viewModel.ExitSaveProgress); + Assert.Equal("Msg.ExitSavingFinishing", viewModel.ExitSaveStatusText); + } + + /// + /// With every disk done but the save still wrapping up, the text says so instead of 'N/N done'. + /// + [Fact] + public void AllDisksDone_ShowsTheFinishingText() + { + using var mountManager = new MountManager(); + using var viewModel = CreateViewModel(mountManager); + viewModel.BeginExitSave(new Dictionary { ["R:"] = 1000, ["S:"] = 1000 }); + + viewModel.ReportExitSaveProgress("R:", 1.0); + viewModel.ReportExitSaveProgress("S:", 1.0); + viewModel.ShowExitSaveProgress(); + + Assert.Equal("Msg.ExitSavingFinishing", viewModel.ExitSaveStatusText); + Assert.True(viewModel.ExitSaveProgress < 1.0); + } + + /// + /// When every disk is finished or failed and one failed, the overlay says so instead of 'k/N'. + /// + [Fact] + public void EndedWithFailures_ShowsTheFailureText() + { + using var mountManager = new MountManager(); + using var viewModel = CreateViewModel(mountManager); + viewModel.BeginExitSave(new Dictionary { ["R:"] = 1000, ["S:"] = 1000 }); + viewModel.ReportExitSaveProgress("R:", 0.4); + viewModel.MarkExitSaveFailed("R:"); + viewModel.ReportExitSaveProgress("S:", 1.0); + + viewModel.ShowExitSaveProgress(); + + Assert.Equal("Msg.ExitSavingFailed", viewModel.ExitSaveStatusText); + } + + /// + /// With nothing to write, completing keeps the generic text instead of naming zero disks. + /// + [Fact] + public void CompleteExitSave_WithNoDisksToWrite_KeepsTheGenericText() + { + using var mountManager = new MountManager(); + using var viewModel = CreateViewModel(mountManager); + viewModel.IsExiting = true; + viewModel.BeginExitSave(new Dictionary()); + + viewModel.CompleteExitSave(); + + Assert.Equal("Msg.ExitSaving", viewModel.ExitSaveStatusText); + Assert.Equal(string.Empty, viewModel.ExitSaveDetailText); + Assert.Equal(1.0, viewModel.ExitSaveProgress); + } + + /// + /// Both language files define the summary text with the same placeholders. + /// + [Theory] + [InlineData("Strings.en-US.xaml")] + [InlineData("Strings.zh-CN.xaml")] + public void SummaryString_ExistsInEveryLanguageWithBothPlaceholders(string file) + { + var xaml = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Localization", file)); + + var match = System.Text.RegularExpressions.Regex.Match(xaml, "x:Key=\"Msg.ExitSavingDisks\">([^<]*)<"); + + Assert.True(match.Success); + Assert.Contains("{0}", match.Groups[1].Value); + Assert.Contains("{1}", match.Groups[1].Value); + } + + /// + /// Both language files define the finishing and failure texts, the latter with both placeholders. + /// + [Theory] + [InlineData("Strings.en-US.xaml")] + [InlineData("Strings.zh-CN.xaml")] + public void FinishingAndFailureStrings_ExistInEveryLanguage(string file) + { + var xaml = File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Localization", file)); + + Assert.Contains("x:Key=\"Msg.ExitSavingFinishing\"", xaml); + var failed = System.Text.RegularExpressions.Regex.Match(xaml, "x:Key=\"Msg.ExitSavingFailed\">([^<]*)<"); + Assert.True(failed.Success); + Assert.Contains("{0}", failed.Groups[1].Value); + Assert.Contains("{1}", failed.Groups[1].Value); + } + + private MainViewModel CreateViewModel(MountManager mountManager) + { + var store = new SettingsStore(Path.Combine(_dir, "settings.json")); + return new MainViewModel(mountManager, store, NullLogger.Instance, store.Load().Disks); + } +}