From 0647f249fb49537fb36ec3ec20f3269badc327cc Mon Sep 17 00:00:00 2001 From: laurentiu021 Date: Thu, 14 May 2026 11:17:18 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20address=206=20medium=20bugs=20=E2=80=94?= =?UTF-8?q?=20DuplicateFileGroup,=20UpdateService,=20ServiceManager,=20Dis?= =?UTF-8?q?kAnalyzer,=20WindowsUpdateVM,=20TuneUpService=20(#311)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 17 +++ .../DiskAnalyzerServiceTests.cs | 13 +++ .../DuplicateFileGroupTests.cs | 110 ++++++++++++++++++ .../SysManager/Models/DuplicateFileGroup.cs | 9 +- .../Services/DiskAnalyzerService.cs | 17 +-- .../Services/ServiceManagerService.cs | 23 ++-- .../SysManager/Services/TuneUpService.cs | 5 +- .../SysManager/Services/UpdateService.cs | 1 + .../ViewModels/WindowsUpdateViewModel.cs | 2 +- 9 files changed, 173 insertions(+), 24 deletions(-) create mode 100644 SysManager/SysManager.Tests/DuplicateFileGroupTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7596ffbc..d1e55faa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.48.3] - 2026-05-14 + +### Fixed +- **DuplicateFileGroup** — WastedBytes now raises PropertyChanged when Count or + FileSize changes (missing NotifyPropertyChangedFor attributes). +- **UpdateService** — pre-release and draft GitHub releases are now filtered out + in GetRecentAsync results. +- **ServiceManagerService** — StartService no longer throws when the service is + already in StartPending state. +- **DiskAnalyzerService** — empty directories are no longer incorrectly flagged + as access-denied; the flag now tracks actual UnauthorizedAccessException. +- **WindowsUpdateViewModel** — null-conditional on Application.Current before + calling Shutdown() prevents NullReferenceException during unit tests or + non-standard hosting. +- **TuneUpService** — SHEmptyRecycleBin HRESULT is now checked; returns false + on failure instead of always reporting success. + ## [0.48.2] - 2026-05-14 ### Fixed diff --git a/SysManager/SysManager.Tests/DiskAnalyzerServiceTests.cs b/SysManager/SysManager.Tests/DiskAnalyzerServiceTests.cs index a3bdfff7..82e63fce 100644 --- a/SysManager/SysManager.Tests/DiskAnalyzerServiceTests.cs +++ b/SysManager/SysManager.Tests/DiskAnalyzerServiceTests.cs @@ -127,6 +127,19 @@ public async Task Analyze_Percentages_ProportionalToSize() Assert.True(big.Percentage > small.Percentage); } + [Fact] + public async Task Analyze_EmptySubfolder_NotMarkedAsAccessDenied() + { + // An empty directory should NOT be flagged as access denied + CreateDir("emptydir"); + var result = await _service.AnalyzeAsync(_root); + Assert.Single(result); + Assert.Equal("emptydir", result[0].Name); + Assert.False(result[0].IsAccessDenied); + Assert.Equal(0, result[0].SizeBytes); + Assert.Equal(0, result[0].FileCount); + } + // ── Invalid inputs ── [Fact] diff --git a/SysManager/SysManager.Tests/DuplicateFileGroupTests.cs b/SysManager/SysManager.Tests/DuplicateFileGroupTests.cs new file mode 100644 index 00000000..db16cf32 --- /dev/null +++ b/SysManager/SysManager.Tests/DuplicateFileGroupTests.cs @@ -0,0 +1,110 @@ +// SysManager · DuplicateFileGroupTests +// Author: laurentiu021 · https://github.com/laurentiu021/SystemManager +// License: MIT + +using SysManager.Models; + +namespace SysManager.Tests; + +/// +/// Tests for model — validates WastedBytes +/// calculation and property change notifications. +/// +public class DuplicateFileGroupTests +{ + [Fact] + public void WastedBytes_CalculatesCorrectly() + { + var group = new DuplicateFileGroup(); + group.FileSize = 1024; + group.Count = 3; + // Wasted = (3 - 1) * 1024 = 2048 + Assert.Equal(2048, group.WastedBytes); + } + + [Fact] + public void WastedBytes_ZeroWhenCountIsOne() + { + var group = new DuplicateFileGroup(); + group.FileSize = 5000; + group.Count = 1; + Assert.Equal(0, group.WastedBytes); + } + + [Fact] + public void WastedBytes_ZeroWhenCountIsZero() + { + var group = new DuplicateFileGroup(); + group.FileSize = 5000; + group.Count = 0; + Assert.Equal(0, group.WastedBytes); + } + + [Fact] + public void WastedBytes_NotifiesWhenFileSizeChanges() + { + var group = new DuplicateFileGroup(); + group.Count = 3; + var changed = new List(); + group.PropertyChanged += (_, e) => changed.Add(e.PropertyName!); + + group.FileSize = 2048; + + Assert.Contains("WastedBytes", changed); + } + + [Fact] + public void WastedBytes_NotifiesWhenCountChanges() + { + var group = new DuplicateFileGroup(); + group.FileSize = 1024; + var changed = new List(); + group.PropertyChanged += (_, e) => changed.Add(e.PropertyName!); + + group.Count = 5; + + Assert.Contains("WastedBytes", changed); + } + + [Fact] + public void Hash_PropertyNotifies() + { + var group = new DuplicateFileGroup(); + var changed = new List(); + group.PropertyChanged += (_, e) => changed.Add(e.PropertyName!); + + group.Hash = "abc123"; + + Assert.Contains("Hash", changed); + } + + [Fact] + public void Files_Collection_IsInitialized() + { + var group = new DuplicateFileGroup(); + Assert.NotNull(group.Files); + Assert.Empty(group.Files); + } + + // ── DuplicateFileEntry tests ── + + [Fact] + public void DuplicateFileEntry_Properties_Notify() + { + var entry = new DuplicateFileEntry(); + var changed = new List(); + entry.PropertyChanged += (_, e) => changed.Add(e.PropertyName!); + + entry.Path = @"C:\test\file.txt"; + entry.Name = "file.txt"; + entry.SizeBytes = 4096; + entry.LastModified = DateTime.Now; + entry.IsSelected = true; + + Assert.Contains("Path", changed); + Assert.Contains("Name", changed); + Assert.Contains("SizeBytes", changed); + Assert.Contains("LastModified", changed); + Assert.Contains("IsSelected", changed); + } +} diff --git a/SysManager/SysManager/Models/DuplicateFileGroup.cs b/SysManager/SysManager/Models/DuplicateFileGroup.cs index bcdea7a8..df7b88f2 100644 --- a/SysManager/SysManager/Models/DuplicateFileGroup.cs +++ b/SysManager/SysManager/Models/DuplicateFileGroup.cs @@ -14,8 +14,13 @@ namespace SysManager.Models; public partial class DuplicateFileGroup : ObservableObject { [ObservableProperty] private string _hash = ""; - [ObservableProperty] private long _fileSize; - [ObservableProperty] private int _count; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(WastedBytes))] + private long _fileSize; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(WastedBytes))] + private int _count; public ObservableCollection Files { get; } = new(); diff --git a/SysManager/SysManager/Services/DiskAnalyzerService.cs b/SysManager/SysManager/Services/DiskAnalyzerService.cs index df5393d2..d21d4929 100644 --- a/SysManager/SysManager/Services/DiskAnalyzerService.cs +++ b/SysManager/SysManager/Services/DiskAnalyzerService.cs @@ -82,7 +82,7 @@ private static IReadOnlyList Analyze( scanned++; progress?.Report(new AnalysisProgress(scanned, Path.GetFileName(dir))); - var (size, files, folders) = MeasureFolder(dir, ct); + var (size, files, folders, denied) = MeasureFolder(dir, ct); var name = Path.GetFileName(dir); if (string.IsNullOrEmpty(name)) name = dir; @@ -93,7 +93,7 @@ private static IReadOnlyList Analyze( SizeBytes = size, FileCount = files, FolderCount = folders, - IsAccessDenied = size == 0 && files == 0 && folders == 0 + IsAccessDenied = denied }); } @@ -124,11 +124,12 @@ private static IReadOnlyList Analyze( return results; } - private static (long size, int files, int folders) MeasureFolder(string path, CancellationToken ct) + private static (long size, int files, int folders, bool accessDenied) MeasureFolder(string path, CancellationToken ct) { long totalSize = 0; int fileCount = 0; int folderCount = 0; + bool accessDenied = false; var stack = new Stack(); stack.Push(path); @@ -140,10 +141,10 @@ private static (long size, int files, int folders) MeasureFolder(string path, Ca string[] files = Array.Empty(); string[] dirs = Array.Empty(); try { files = Directory.GetFiles(current); } - catch (UnauthorizedAccessException) { /* skip protected folder */ } + catch (UnauthorizedAccessException) { accessDenied = true; } catch (IOException) { /* skip inaccessible folder */ } try { dirs = Directory.GetDirectories(current); } - catch (UnauthorizedAccessException) { /* skip protected folder */ } + catch (UnauthorizedAccessException) { accessDenied = true; } catch (IOException) { /* skip inaccessible folder */ } foreach (var f in files) @@ -154,7 +155,7 @@ private static (long size, int files, int folders) MeasureFolder(string path, Ca totalSize += new FileInfo(f).Length; fileCount++; } - catch (UnauthorizedAccessException) { /* skip inaccessible file */ } + catch (UnauthorizedAccessException) { accessDenied = true; } catch (IOException) { /* skip inaccessible file */ } } @@ -167,7 +168,7 @@ private static (long size, int files, int folders) MeasureFolder(string path, Ca var attr = File.GetAttributes(d); if ((attr & FileAttributes.ReparsePoint) != 0) continue; } - catch (UnauthorizedAccessException) { continue; } + catch (UnauthorizedAccessException) { accessDenied = true; continue; } catch (IOException) { continue; } folderCount++; @@ -175,7 +176,7 @@ private static (long size, int files, int folders) MeasureFolder(string path, Ca } } - return (totalSize, fileCount, folderCount); + return (totalSize, fileCount, folderCount, accessDenied); } private static bool ShouldSkip(string path) diff --git a/SysManager/SysManager/Services/ServiceManagerService.cs b/SysManager/SysManager/Services/ServiceManagerService.cs index 7e2eb8f2..a1c819ea 100644 --- a/SysManager/SysManager/Services/ServiceManagerService.cs +++ b/SysManager/SysManager/Services/ServiceManagerService.cs @@ -86,18 +86,19 @@ public static List GetAllServices() public static void StartService(string serviceName) { using var sc = new ServiceController(serviceName); - if (sc.Status != ServiceControllerStatus.Running) + if (sc.Status == ServiceControllerStatus.Running || + sc.Status == ServiceControllerStatus.StartPending) + return; + + sc.Start(); + try { - sc.Start(); - try - { - sc.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(30)); - } - catch (System.ServiceProcess.TimeoutException) - { - throw new InvalidOperationException( - $"Service '{serviceName}' did not start within 30 seconds. It may still be starting — check Services again in a moment."); - } + sc.WaitForStatus(ServiceControllerStatus.Running, TimeSpan.FromSeconds(30)); + } + catch (System.ServiceProcess.TimeoutException) + { + throw new InvalidOperationException( + $"Service '{serviceName}' did not start within 30 seconds. It may still be starting — check Services again in a moment."); } } diff --git a/SysManager/SysManager/Services/TuneUpService.cs b/SysManager/SysManager/Services/TuneUpService.cs index 9024e9ce..5ac1ea85 100644 --- a/SysManager/SysManager/Services/TuneUpService.cs +++ b/SysManager/SysManager/Services/TuneUpService.cs @@ -218,8 +218,9 @@ private static bool EmptyRecycleBin() try { // SHEmptyRecycleBin with SHERB_NOCONFIRMATION | SHERB_NOPROGRESSUI | SHERB_NOSOUND - NativeMethods.SHEmptyRecycleBin(IntPtr.Zero, null, 0x00000007); - return true; + int hr = NativeMethods.SHEmptyRecycleBin(IntPtr.Zero, null, 0x00000007); + // S_OK (0) = success, 0x80070012 (ERROR_NO_MORE_FILES) = bin already empty + return hr >= 0 || unchecked((uint)hr) == 0x80070012; } catch (System.ComponentModel.Win32Exception ex) { diff --git a/SysManager/SysManager/Services/UpdateService.cs b/SysManager/SysManager/Services/UpdateService.cs index 32a073a4..f4a0554f 100644 --- a/SysManager/SysManager/Services/UpdateService.cs +++ b/SysManager/SysManager/Services/UpdateService.cs @@ -260,6 +260,7 @@ public static Version CurrentVersion private static ReleaseInfo? Map(GhRelease dto) { if (dto.TagName == null) return null; + if (dto.Prerelease || dto.Draft) return null; var version = ParseVersion(dto.TagName); if (version == null) return null; diff --git a/SysManager/SysManager/ViewModels/WindowsUpdateViewModel.cs b/SysManager/SysManager/ViewModels/WindowsUpdateViewModel.cs index cbbcc945..97d6725b 100644 --- a/SysManager/SysManager/ViewModels/WindowsUpdateViewModel.cs +++ b/SysManager/SysManager/ViewModels/WindowsUpdateViewModel.cs @@ -74,7 +74,7 @@ private async Task AutoCheckOnStartAsync() private void RelaunchAsAdmin() { if (AdminHelper.RelaunchAsAdmin()) - System.Windows.Application.Current.Shutdown(); + System.Windows.Application.Current?.Shutdown(); } [RelayCommand]