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); + } +}