From 7da9ded1498fe60fd119c9892da4b3cd4587404b Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Fri, 22 May 2026 13:01:47 +0200 Subject: [PATCH 01/12] Fix startup binding and prep v1.2.0 --- Installer/Installer.iss | 2 +- Installer/ThreadPilot.wxs | 2 +- Installer/setup.iss | 2 +- README.md | 41 ++-- .../PersistentRuleAutoApplyServiceTests.cs | 145 ++++++++++++ .../PersistentRulesEngineTests.cs | 44 ++++ .../ProcessMonitorManagerServiceTests.cs | 220 +++++++++++++++++- .../ProcessViewXamlBindingTests.cs | 67 ++++++ .../ServiceConfigurationTests.cs | 45 ++++ ThreadPilot.csproj | 8 +- Views/ProcessView.xaml | 2 +- app.manifest | 2 +- build/build-installer.ps1 | 2 +- build/build-release.ps1 | 2 +- build/package-release-zips.ps1 | 2 +- chocolatey/threadpilot.nuspec | 4 +- docs/CHANGELOG.md | 38 +++ docs/release/RELEASE_NOTES.md | 67 +++--- docs/releases/v1.2.0-checklist.md | 15 ++ docs/releases/v1.2.0.md | 44 ++++ sonar-project.properties | 2 +- 21 files changed, 684 insertions(+), 72 deletions(-) create mode 100644 Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs create mode 100644 Tests/ThreadPilot.Core.Tests/ServiceConfigurationTests.cs create mode 100644 docs/releases/v1.2.0-checklist.md create mode 100644 docs/releases/v1.2.0.md diff --git a/Installer/Installer.iss b/Installer/Installer.iss index ff61775..8beead0 100644 --- a/Installer/Installer.iss +++ b/Installer/Installer.iss @@ -5,7 +5,7 @@ #define MyAppPublisher "ThreadPilot" #define MyAppURL "https://github.com/" #define MyAppExeName "ThreadPilot.exe" -#define MyAppVersion "0.1.0-beta" +#define MyAppVersion "1.2.0" #ifndef MyWizardStyle #define MyWizardStyle "modern dynamic windows11" diff --git a/Installer/ThreadPilot.wxs b/Installer/ThreadPilot.wxs index c67b3c3..c924f7c 100644 --- a/Installer/ThreadPilot.wxs +++ b/Installer/ThreadPilot.wxs @@ -7,7 +7,7 @@ diff --git a/Installer/setup.iss b/Installer/setup.iss index 75ce6fe..9cfc206 100644 --- a/Installer/setup.iss +++ b/Installer/setup.iss @@ -11,7 +11,7 @@ #endif #ifndef MyAppVersion - #define MyAppVersion "1.1.3" + #define MyAppVersion "1.2.0" #endif #ifndef MyAppSourceDir diff --git a/README.md b/README.md index d9b6680..da6b5e7 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # ThreadPilot ✈️ -**A free and open-source Windows process and power plan manager for deterministic performance workflows.** +**A free and open-source Windows process control center for deterministic CPU, priority, memory, and power-plan workflows.** [![Build](https://github.com/PrimeBuild-pc/ThreadPilot/actions/workflows/ci-devsecops.yml/badge.svg)](https://github.com/PrimeBuild-pc/ThreadPilot/actions/workflows/ci-devsecops.yml) [![Release](https://img.shields.io/github/v/release/PrimeBuild-pc/ThreadPilot?sort=semver)](https://github.com/PrimeBuild-pc/ThreadPilot/releases) @@ -21,20 +21,27 @@ ## What is ThreadPilot? -ThreadPilot is a modern Windows desktop application for users who want predictable control over process behavior, CPU affinity, priority, power plans, and rule-driven performance workflows. - -It is designed as an open-source alternative for power users who need Process Lasso-style capabilities, automation support, system tray controls, and a Windows 11-first experience. +ThreadPilot is a modern Windows process control center for users who want predictable control over process behavior, CPU affinity, CPU Sets, priority, memory priority, power plans, and saved process rules. + +It is designed as an open-source alternative for power users who need Process Lasso-style capabilities, automation support, system tray controls, and a Windows 11-first experience. ThreadPilot is not a performance overlay: its primary job is applying explicit process controls safely and making the result visible. ## ✨ Features -- Live process management with refresh, filtering, and high-volume process handling. -- CPU affinity and priority controls with topology-aware logic. -- I/O and scheduler-related tuning utilities. -- Rule-based automation for power plan switching when selected processes start or stop. -- Conditional profiles, tray controls, Live Metrics, and dashboard views. -- Administrator-aware Windows desktop workflow. -- CI-backed release artifacts and package-manager distribution. -- Windows 11 native visual refresh with neutral Fluent surfaces and refined card-based layouts. +- Live process management with refresh, filtering, context-menu actions, and a selected-process summary. +- Topology-aware CPU affinity through `CpuSelection`, including CPU Sets support, processor groups, and safe handling for systems with more than 64 logical processors. +- Safer CPU indexing in new affinity paths: CPU64 no longer aliases CPU0. +- Intel hybrid CPU handling through Windows topology and `EfficiencyClass`, not hardcoded SKU lists. +- AMD CCD/L3-aware preset generation, also topology-driven instead of hardcoded SKU lists. +- Default gaming-oriented CPU presets for common foreground-game workflows. +- CPU priority controls with guardrails: High priority warning and Realtime priority blocked. +- Process memory priority support. +- Persistent process rules with explicit Apply now and Save as rule flows. +- Apply saved rules automatically when matching processes start while ThreadPilot is running. +- Rule-based automation for power plan switching when selected processes start or stop. +- Optional Diagnostics view, hidden by default, plus tray controls and dashboard views. +- Administrator-aware Windows desktop workflow. +- CI-backed build validation and package-manager distribution. +- Windows 11 native visual refresh with neutral Fluent surfaces and refined card-based layouts. ## 📦 Install @@ -86,9 +93,13 @@ Compare the result with `SHA256SUMS.txt` from the same release. ## 🚀 Usage Notes -ThreadPilot uses an administrator-required manifest and requests elevation at startup. If UAC elevation is declined, the application exits instead of continuing in a limited mode. - -Useful startup arguments: +ThreadPilot uses an administrator-required manifest and requests elevation at startup. If UAC elevation is declined, the application exits instead of continuing in a limited mode. + +Persistent process rules are runtime-based. Apply at process start works only while ThreadPilot is running; it does not install a Windows Service, write registry or IFEO persistence, or use installer privilege tricks. + +ThreadPilot does not bypass anti-cheat or protected-process restrictions. Running as administrator may help with normal access-denied cases, but it does not override protected-process or anti-cheat enforcement. + +Useful startup arguments: ```text --start-minimized diff --git a/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs index 71c8c87..abf287e 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs @@ -194,6 +194,51 @@ await Assert.ThrowsAsync(() => service.ApplyForProcessStartAsync(process)); } + [Fact] + public async Task ApplyForProcessStartAsync_FeatureFlagDisabled_DoesNotCallRulesEngine() + { + var process = CreateProcess(); + var rule = CreateRule(); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService( + [rule], + engine.Object, + settings: new ApplicationSettingsModel { ApplyPersistentRulesOnProcessStart = false }); + + var results = await service.ApplyForProcessStartAsync(process); + + Assert.Empty(results); + engine.Verify( + x => x.ApplyMatchingRulesAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + + [Theory] + [InlineData(0, "game.exe")] + [InlineData(42, "")] + [InlineData(42, " ")] + public async Task ApplyForProcessStartAsync_WithInvalidProcess_DoesNotCallRulesEngine(int processId, string processName) + { + var process = CreateProcess(processName); + process.ProcessId = processId; + var rule = CreateRule(); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService([rule], engine.Object); + + var results = await service.ApplyForProcessStartAsync(process); + + Assert.Empty(results); + engine.Verify( + x => x.ApplyMatchingRulesAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Never); + } + [Fact] public async Task ApplyForDiscoveredProcessesAsync_FeatureFlagDisabled_DoesNotCallRulesEngine() { @@ -216,6 +261,26 @@ public async Task ApplyForDiscoveredProcessesAsync_FeatureFlagDisabled_DoesNotCa Times.Never); } + [Fact] + public async Task ApplyForDiscoveredProcessesAsync_GroupsDuplicateProcessesByProcessId() + { + var process = CreateProcess(); + var duplicate = CreateProcess("game.exe"); + duplicate.ExecutablePath = @"C:\Games\GameCopy.exe"; + var rule = CreateRule(); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService([rule], engine.Object); + + await service.ApplyForDiscoveredProcessesAsync([process, duplicate]); + + engine.Verify( + x => x.ApplyMatchingRulesAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny()), + Times.Once); + } + [Fact] public async Task ApplyForDiscoveredProcessesAsync_ClearsCooldownForProcessesNoLongerPresent() { @@ -236,6 +301,74 @@ public async Task ApplyForDiscoveredProcessesAsync_ClearsCooldownForProcessesNoL Times.Exactly(2)); } + [Fact] + public async Task ApplyForProcessStartAsync_WhenRuleUpdatedDuringCooldown_AllowsReapply() + { + var process = CreateProcess(); + var rule = CreateRule(); + var rules = new List { rule }; + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService(rules, engine.Object); + + await service.ApplyForProcessStartAsync(process); + rules[0] = rule with { UpdatedAt = rule.UpdatedAt.AddSeconds(1) }; + await service.ApplyForProcessStartAsync(process); + + engine.Verify( + x => x.ApplyMatchingRulesAsync( + process, + It.IsAny?>(), + It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public async Task ApplyForProcessStartAsync_WhenRulesEngineThrows_ReturnsControlledFailure() + { + var process = CreateProcess(); + var rule = CreateRule(); + var engine = CreateEngineThatThrows(new InvalidOperationException("native apply failed")); + var service = CreateService([rule], engine.Object); + + var result = Assert.Single(await service.ApplyForProcessStartAsync(process)); + + Assert.False(result.Success); + Assert.Equal(rule.Id, result.RuleId); + Assert.Equal(process.ProcessId, result.ProcessId); + Assert.Equal("ThreadPilot could not apply the saved rule.", result.UserMessage); + Assert.Equal("native apply failed", result.TechnicalMessage); + } + + [Fact] + public async Task MarkProcessExited_RemovesOnlyMatchingProcessAttempts() + { + var process = CreateProcess(); + var otherProcess = CreateProcess("game.exe"); + otherProcess.ProcessId = 84; + var rule = CreateRule(); + var engine = CreateEngine(rule, CreateSuccess(rule, process)); + var service = CreateService([rule], engine.Object); + + await service.ApplyForProcessStartAsync(process); + await service.ApplyForProcessStartAsync(otherProcess); + service.MarkProcessExited(process.ProcessId); + await service.ApplyForProcessStartAsync(process); + await service.ApplyForProcessStartAsync(otherProcess); + + engine.Verify( + x => x.ApplyMatchingRulesAsync( + process, + It.IsAny?>(), + It.IsAny()), + Times.Exactly(2)); + engine.Verify( + x => x.ApplyMatchingRulesAsync( + otherProcess, + It.IsAny?>(), + It.IsAny()), + Times.Once); + } + private static PersistentRuleAutoApplyService CreateService( IReadOnlyList rules, IPersistentRulesEngine engine, @@ -279,6 +412,18 @@ private static Mock CreateEngineThatCancels() return engine; } + private static Mock CreateEngineThatThrows(Exception exception) + { + var engine = new Mock(MockBehavior.Strict); + engine + .Setup(x => x.ApplyMatchingRulesAsync( + It.IsAny(), + It.IsAny?>(), + It.IsAny())) + .ThrowsAsync(exception); + return engine; + } + private static IApplicationSettingsService CreateSettingsService(ApplicationSettingsModel settings) { var settingsService = new Mock(MockBehavior.Loose); diff --git a/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs index 69d605b..71daf66 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentRulesEngineTests.cs @@ -337,6 +337,50 @@ public async Task ApplyMatchingRulesAsync_WithMultipleMatchingRules_ReturnsResul processService.Verify(s => s.SetProcessPriority(It.IsAny(), It.IsAny()), Times.Once); } + [Fact] + public async Task ApplyMatchingRulesAsync_WhenRuleFilterExcludesMatchingRule_DoesNotApplyIt() + { + var rules = new[] + { + CreateRule(id: "rule-a", legacyAffinityMask: 1, applyAffinity: true), + CreateRule(id: "rule-b", legacyAffinityMask: 2, applyAffinity: true), + }; + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var engine = CreateEngine(rules, affinity.Object, processService.Object, CreateMemoryPriorityService().Object); + + var results = await engine.ApplyMatchingRulesAsync( + CreateProcess(), + rule => rule.Id != "rule-a"); + + var result = Assert.Single(results); + Assert.Equal("rule-b", result.RuleId); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), 1L), Times.Never); + affinity.Verify(s => s.ApplyAsync(It.IsAny(), 2L), Times.Once); + } + + [Fact] + public async Task ApplyMatchingRulesAsync_WhenRuleFilterIncludesSelectedRule_AppliesOnlyThatRule() + { + var rules = new[] + { + CreateRule(id: "rule-a", priority: ProcessPriorityClass.AboveNormal, applyPriority: true), + CreateRule(id: "rule-b", priority: ProcessPriorityClass.High, applyPriority: true), + }; + var affinity = CreateAffinityService(); + var processService = CreateProcessService(); + var engine = CreateEngine(rules, affinity.Object, processService.Object, CreateMemoryPriorityService().Object); + + var results = await engine.ApplyMatchingRulesAsync( + CreateProcess(), + rule => rule.Id == "rule-b"); + + var result = Assert.Single(results); + Assert.Equal("rule-b", result.RuleId); + processService.Verify(s => s.SetProcessPriority(It.IsAny(), ProcessPriorityClass.AboveNormal), Times.Never); + processService.Verify(s => s.SetProcessPriority(It.IsAny(), ProcessPriorityClass.High), Times.Once); + } + private static PersistentRulesEngine CreateEngine( IReadOnlyList rules, IAffinityApplyService affinityApplyService, diff --git a/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs index a8ef596..6176337 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs @@ -430,6 +430,195 @@ public async Task ProcessStarted_WhenPersistentRuleAutoApplyCancels_DoesNotLogWa Assert.Empty(logger.WarningMessages); } + [Fact] + public async Task EvaluateCurrentProcessesAsync_WhenPersistentRuleAutoApplyThrows_LogsWarningWithoutBreakingRefresh() + { + var processMonitor = new FakeProcessMonitorService + { + RunningProcesses = + { + new ProcessModel { ProcessId = 44, Name = "game.exe" }, + }, + }; + var configuration = new ProcessMonitorConfiguration(); + var associationService = CreateAssociationService(configuration); + var powerPlanService = CreatePowerPlanService(); + var notificationService = CreateNotificationService(); + var processService = CreateProcessService(); + var coreMaskService = CreateCoreMaskService(); + var affinityApplyService = CreateAffinityApplyService(); + var autoApplyService = CreateAutoApplyService(); + autoApplyService + .Setup(x => x.ApplyForDiscoveredProcessesAsync( + It.IsAny>(), + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("auto apply failed")); + var logger = new CapturingLogger(); + var manager = CreateService( + processMonitor, + associationService, + powerPlanService, + notificationService, + processService, + coreMaskService, + affinityApplyService, + autoApplyService, + logger); + + await manager.StartAsync(); + await manager.EvaluateCurrentProcessesAsync(); + + Assert.Contains( + logger.WarningMessages, + message => message.Contains("snapshot refresh", StringComparison.OrdinalIgnoreCase)); + Assert.True(manager.IsRunning); + } + + [Fact] + public async Task ProcessStarted_WhenPersistentRuleAutoApplyThrows_LogsWarningWithoutBreakingStartHandling() + { + var process = new ProcessModel { ProcessId = 45, Name = "game.exe" }; + var processMonitor = new FakeProcessMonitorService(); + var configuration = new ProcessMonitorConfiguration(); + var associationService = CreateAssociationService(configuration); + var powerPlanService = CreatePowerPlanService(); + var notificationService = CreateNotificationService(); + var processService = CreateProcessService(); + var coreMaskService = CreateCoreMaskService(); + var affinityApplyService = CreateAffinityApplyService(); + var autoApplyService = CreateAutoApplyService(); + autoApplyService + .Setup(x => x.ApplyForProcessStartAsync( + process, + It.IsAny())) + .ThrowsAsync(new InvalidOperationException("auto apply failed")); + var logger = new CapturingLogger(); + var manager = CreateService( + processMonitor, + associationService, + powerPlanService, + notificationService, + processService, + coreMaskService, + affinityApplyService, + autoApplyService, + logger); + + await manager.StartAsync(); + processMonitor.RaiseProcessStarted(process); + await Task.Delay(100); + + Assert.Contains( + logger.WarningMessages, + message => message.Contains("Persistent rule auto-apply failed", StringComparison.OrdinalIgnoreCase)); + Assert.True(manager.IsRunning); + } + + [Fact] + public async Task ProcessStarted_WhenPersistentRuleAutoApplySucceeds_LogsEnhancedMonitoringEvent() + { + var process = new ProcessModel { ProcessId = 46, Name = "game.exe" }; + var processMonitor = new FakeProcessMonitorService(); + var configuration = new ProcessMonitorConfiguration(); + var associationService = CreateAssociationService(configuration); + var powerPlanService = CreatePowerPlanService(); + var notificationService = CreateNotificationService(); + var processService = CreateProcessService(); + var coreMaskService = CreateCoreMaskService(); + var affinityApplyService = CreateAffinityApplyService(); + var autoApplyService = CreateAutoApplyService(); + autoApplyService + .Setup(x => x.ApplyForProcessStartAsync( + process, + It.IsAny())) + .ReturnsAsync(new[] + { + new PersistentRuleAutoApplyResult + { + Success = true, + RuleId = "rule-game", + ProcessId = process.ProcessId, + ProcessName = process.Name, + UserMessage = "Persistent rule applied.", + }, + }); + var enhancedLogger = CreateEnhancedLogger(); + var manager = CreateService( + processMonitor, + associationService, + powerPlanService, + notificationService, + processService, + coreMaskService, + affinityApplyService, + autoApplyService, + enhancedLogger: enhancedLogger); + + await manager.StartAsync(); + processMonitor.RaiseProcessStarted(process); + await Task.Delay(100); + + enhancedLogger.Verify( + x => x.LogProcessMonitoringEventAsync( + LogEventTypes.ProcessMonitoring.AssociationTriggered, + process.Name, + process.ProcessId, + It.Is(message => message.Contains("Persistent rule 'rule-game' applied automatically", StringComparison.Ordinal))), + Times.Once); + } + + [Fact] + public async Task ProcessStarted_WhenPersistentRuleAutoApplyReturnsFailure_DoesNotNotifyOrThrow() + { + var process = new ProcessModel { ProcessId = 47, Name = "game.exe" }; + var processMonitor = new FakeProcessMonitorService(); + var configuration = new ProcessMonitorConfiguration(); + var associationService = CreateAssociationService(configuration); + var powerPlanService = CreatePowerPlanService(); + var notificationService = CreateNotificationService(); + var processService = CreateProcessService(); + var coreMaskService = CreateCoreMaskService(); + var affinityApplyService = CreateAffinityApplyService(); + var autoApplyService = CreateAutoApplyService(); + autoApplyService + .Setup(x => x.ApplyForProcessStartAsync( + process, + It.IsAny())) + .ReturnsAsync(new[] + { + new PersistentRuleAutoApplyResult + { + Success = false, + RuleId = "rule-game", + ProcessId = process.ProcessId, + ProcessName = process.Name, + UserMessage = ProcessOperationUserMessages.AccessDenied, + IsAccessDenied = true, + }, + }); + var manager = CreateService( + processMonitor, + associationService, + powerPlanService, + notificationService, + processService, + coreMaskService, + affinityApplyService, + autoApplyService); + + await manager.StartAsync(); + processMonitor.RaiseProcessStarted(process); + await Task.Delay(100); + + Assert.True(manager.IsRunning); + notificationService.Verify( + x => x.ShowNotificationAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + notificationService.Verify( + x => x.ShowErrorNotificationAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + [Fact] public async Task Dispose_CompletesOnBlockingSynchronizationContext() { @@ -496,18 +685,10 @@ private static ProcessMonitorManagerService CreateService( Mock coreMaskService, Mock affinityApplyService, Mock? autoApplyService = null, - ILogger? logger = null) + ILogger? logger = null, + Mock? enhancedLogger = null) { - var enhancedLogger = new Mock(MockBehavior.Loose); - enhancedLogger - .Setup(x => x.LogSystemEventAsync(It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - enhancedLogger - .Setup(x => x.LogProcessMonitoringEventAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) - .Returns(Task.CompletedTask); - enhancedLogger - .Setup(x => x.LogErrorAsync(It.IsAny(), It.IsAny(), It.IsAny?>())) - .Returns(Task.CompletedTask); + var resolvedEnhancedLogger = enhancedLogger ?? CreateEnhancedLogger(); var settingsService = new Mock(MockBehavior.Loose); settingsService.SetupGet(x => x.Settings).Returns(new ApplicationSettingsModel()); @@ -524,7 +705,22 @@ private static ProcessMonitorManagerService CreateService( (autoApplyService ?? CreateAutoApplyService()).Object, new PowerPlanTransitionGate(TimeSpan.FromSeconds(2), () => DateTimeOffset.UtcNow), logger ?? NullLogger.Instance, - enhancedLogger.Object); + resolvedEnhancedLogger.Object); + } + + private static Mock CreateEnhancedLogger() + { + var enhancedLogger = new Mock(MockBehavior.Loose); + enhancedLogger + .Setup(x => x.LogSystemEventAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + enhancedLogger + .Setup(x => x.LogProcessMonitoringEventAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + enhancedLogger + .Setup(x => x.LogErrorAsync(It.IsAny(), It.IsAny(), It.IsAny?>())) + .Returns(Task.CompletedTask); + return enhancedLogger; } private static Mock CreateAssociationService(ProcessMonitorConfiguration configuration) diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs new file mode 100644 index 0000000..f54d0d8 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs @@ -0,0 +1,67 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Xml.Linq; + + public sealed class ProcessViewXamlBindingTests + { + private static readonly string ProcessViewPath = Path.Combine( + AppContext.BaseDirectory, + "..", + "..", + "..", + "..", + "..", + "Views", + "ProcessView.xaml"); + + [Fact] + public void LastOperationMessageBinding_IsDisplayOnly() + { + var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); + var lastOperationBindings = document + .Descendants() + .SelectMany(element => element.Attributes().Select(attribute => new + { + Element = element.Name.LocalName, + Attribute = attribute.Name.LocalName, + Value = attribute.Value, + })) + .Where(attribute => attribute.Value.Contains("SelectedProcessSummary.LastOperationMessage", StringComparison.Ordinal)) + .ToList(); + + var binding = Assert.Single(lastOperationBindings); + Assert.Equal("Text", binding.Attribute); + Assert.Contains("Mode=OneWay", binding.Value, StringComparison.Ordinal); + } + + [Fact] + public void SelectedProcessSummaryBindings_AreNotUsedByEditableControls() + { + var editableControls = new HashSet(StringComparer.Ordinal) + { + "CheckBox", + "ComboBox", + "DatePicker", + "PasswordBox", + "Slider", + "TextBox", + "ToggleButton", + }; + var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); + + var editableSummaryBindings = document + .Descendants() + .Where(element => editableControls.Contains(element.Name.LocalName)) + .SelectMany(element => element.Attributes().Select(attribute => new + { + Element = element.Name.LocalName, + Attribute = attribute.Name.LocalName, + Value = attribute.Value, + })) + .Where(attribute => attribute.Value.Contains("SelectedProcessSummary.", StringComparison.Ordinal)) + .ToList(); + + Assert.Empty(editableSummaryBindings); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ServiceConfigurationTests.cs b/Tests/ThreadPilot.Core.Tests/ServiceConfigurationTests.cs new file mode 100644 index 0000000..80bc93e --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/ServiceConfigurationTests.cs @@ -0,0 +1,45 @@ +/* + * ThreadPilot - dependency injection registration tests. + */ +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.DependencyInjection; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class ServiceConfigurationTests + { + [Fact] + public void ConfigureApplicationServices_RegistersPersistentRuleAutoApplyService() + { + using var provider = CreateProvider(); + + var service = provider.GetRequiredService(); + + Assert.IsType(service); + } + + [Fact] + public void ConfigureApplicationServices_RegistersProcessRuleCreationService() + { + using var provider = CreateProvider(); + + var service = provider.GetRequiredService(); + + Assert.IsType(service); + } + + [Fact] + public void ApplicationSettings_DefaultsToPersistentRulesAutoApplyEnabled() + { + var settings = new ApplicationSettingsModel(); + + Assert.True(settings.ApplyPersistentRulesOnProcessStart); + } + + private static ServiceProvider CreateProvider() => + new ServiceCollection() + .ConfigureApplicationServices() + .BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true }); + } +} diff --git a/ThreadPilot.csproj b/ThreadPilot.csproj index 09cf7b1..d660017 100644 --- a/ThreadPilot.csproj +++ b/ThreadPilot.csproj @@ -16,10 +16,10 @@ link true CS1998;CS0067;CS0414;WFAC010;IL3000;MVVMTK0034 - 1.1.6 - 1.1.6.0 - 1.1.6.0 - 1.1.6 + 1.2.0 + 1.2.0.0 + 1.2.0.0 + 1.2.0 diff --git a/Views/ProcessView.xaml b/Views/ProcessView.xaml index 897b97f..a14af30 100644 --- a/Views/ProcessView.xaml +++ b/Views/ProcessView.xaml @@ -312,7 +312,7 @@ Foreground="{DynamicResource TextSecondaryBrush}" TextWrapping="Wrap"> - + diff --git a/app.manifest b/app.manifest index a6ccea0..970b893 100644 --- a/app.manifest +++ b/app.manifest @@ -1,6 +1,6 @@ - + diff --git a/build/build-installer.ps1 b/build/build-installer.ps1 index cb1d61f..5ef8e30 100644 --- a/build/build-installer.ps1 +++ b/build/build-installer.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "1.1.3", + [string]$Version = "1.2.0", [string]$Configuration = "Release", [switch]$SkipPublish ) diff --git a/build/build-release.ps1 b/build/build-release.ps1 index 425f8b7..95dab48 100644 --- a/build/build-release.ps1 +++ b/build/build-release.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "1.1.3", + [string]$Version = "1.2.0", [string]$Configuration = "Release", [string]$Runtime = "win-x64" ) diff --git a/build/package-release-zips.ps1 b/build/package-release-zips.ps1 index eb73351..b42c23a 100644 --- a/build/package-release-zips.ps1 +++ b/build/package-release-zips.ps1 @@ -1,5 +1,5 @@ param( - [string]$Version = "1.1.3" + [string]$Version = "1.2.0" ) $ErrorActionPreference = "Stop" diff --git a/chocolatey/threadpilot.nuspec b/chocolatey/threadpilot.nuspec index e6aca99..2cbcdc1 100644 --- a/chocolatey/threadpilot.nuspec +++ b/chocolatey/threadpilot.nuspec @@ -2,7 +2,7 @@ threadpilot - 1.1.3 + 1.2.0 ThreadPilot Prime Build https://github.com/PrimeBuild-pc/ThreadPilot @@ -15,7 +15,7 @@ false Advanced Windows process and power plan manager with rules automation and performance controls. ThreadPilot process and power plan manager. - https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.1.3 + https://github.com/PrimeBuild-pc/ThreadPilot/releases/tag/v1.2.0 threadpilot process powerplan performance windows diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 7ea5646..ce8218b 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,44 @@ All notable changes to this project are documented in this file. +## v1.2.0 - CPU topology, persistent rules, and process control update + +### Added + +- CPU topology v2 support with `CpuSelection` for topology-aware affinity. +- Group-aware CPU Sets support and safer handling for processor groups and systems with more than 64 logical processors. +- Memory priority controls and persistent process rules. +- Apply at process start for saved rules while ThreadPilot is running. +- Process tab context menu actions, explicit Apply now, and Save as rule flows. +- Selected process summary panel for current affinity, priority, memory priority, and last operation status. +- Optional Diagnostics experience hidden by default. + +### Changed + +- README and release documentation now describe ThreadPilot as a process control center rather than a performance overlay. +- Default presets are gaming-oriented and topology-aware. +- Intel hybrid handling uses topology and `EfficiencyClass` instead of hardcoded SKU lists. +- AMD preset generation is CCD/L3-aware and avoids hardcoded SKU lists. +- Project version updated to 1.2.0. + +### Fixed + +- Startup binding crash caused by a display-only selected-process summary message binding to a read-only property with a TwoWay-capable target. +- CPU64 no longer aliases CPU0 in the new safe affinity paths. +- Persistent rule auto-apply cancellation is handled as shutdown/cancellation instead of logged as a warning. + +### Safety + +- CPU priority guardrails warn for High priority and block Realtime priority. +- Anti-cheat/protected-process failures use safe user messaging and ThreadPilot does not bypass protected processes. +- Persistent rules reuse the existing affinity, priority, memory-priority, and Realtime guardrail backend instead of duplicating apply logic. + +### Notes / limitations + +- Apply at process start is runtime-based and works only while ThreadPilot is running. +- No Windows Service, registry autorun, IFEO persistence, installer privilege workaround, tag, GitHub release, or generated release artifact is included in this update. +- Administrator rights can help normal access-denied cases but do not bypass protected-process or anti-cheat restrictions. + ## [1.1.6] - 2026-05-16 ### Added diff --git a/docs/release/RELEASE_NOTES.md b/docs/release/RELEASE_NOTES.md index 0f6c6e8..f434581 100644 --- a/docs/release/RELEASE_NOTES.md +++ b/docs/release/RELEASE_NOTES.md @@ -1,50 +1,57 @@ -# ThreadPilot v1.1.6 Release Notes +# ThreadPilot v1.2.0 Release Notes Draft ## Highlights -- Windows 11 native visual refresh across all major views with neutral Fluent surfaces. -- Refined sidebar navigation: separator lines softened for a cleaner, Settings-like appearance. -- Reduced visual weight and consistent card-based layouts in Rules, Logs, Performance, Settings, Tweaks, Process, Power Plans, and CPU Masks. -- Start minimized default clarified: `StartMinimized` now explicitly defaults to `false` for predictable manual-launch visibility. +- CPU topology v2 with topology-aware `CpuSelection`, CPU Sets, processor groups, and safer handling above 64 logical processors. +- New safe affinity paths where CPU64 no longer aliases CPU0. +- Intel hybrid handling through topology and `EfficiencyClass`, plus AMD CCD/L3-aware preset generation. +- Memory priority support and persistent process rules. +- Apply saved rules automatically when matching processes start while ThreadPilot is running. +- Process tab context menu actions, Save as rule, Apply now, and selected-process summary. ## Added -- Windows 11 visual refresh pass completed for neutral Fluent surfaces and card polish. -- Sidebar navigation separator polish: horizontal separator lines removed/softened for a native Windows 11 Settings-like feel. +- CPU topology v2 and `CpuSelection` for topology-aware affinity. +- Group-aware CPU Sets support and processor group safety. +- Memory priority controls. +- Persistent process rules with runtime apply-at-process-start support. +- Process tab context menu actions and selected-process summary. +- Optional Diagnostics view hidden by default. ## Changed -- `StartMinimized` defaults to `false` in `ApplicationSettingsModel`: manual exe launch opens the main window visibly by default. -- Older settings JSON without `startMinimized` field now reliably defaults to `false`. -- Explicit saved `startMinimized: true` or `startMinimized: false` values remain fully respected. -- Project version updated to 1.1.6. +- Default presets are gaming-oriented and generated from topology rather than hardcoded CPU SKU lists. +- Intel hybrid behavior uses Windows topology and `EfficiencyClass`. +- AMD behavior uses CCD/L3-aware preset generation. +- Project version updated to 1.2.0. ## Fixed -- Legacy settings without `startMinimized` no longer risk unexpected minimized startup. +- Startup no longer fails from a read-only selected-process summary binding. +- CPU64 no longer aliases CPU0 in new safe affinity paths. +- Persistent rule auto-apply cancellation does not log shutdown/future cancellation as a warning. -## Breaking Changes +## Safety -- None. +- High CPU priority shows a warning and Realtime priority remains blocked. +- ThreadPilot does not bypass anti-cheat or protected-process restrictions. +- Administrator rights may help ordinary access-denied cases but do not bypass protected processes. -## Installation +## Compatibility and Upgrade Notes -### Installer +- Requires Windows 11 build 22000 or newer. +- Existing legacy affinity profiles continue to load. +- New saved rules prefer topology-aware `CpuSelection` when safe topology mapping is available. +- Apply at process start works only while ThreadPilot is running. -1. Download ThreadPilot_v1.1.6_Setup.exe. -2. Run installer. -3. Launch ThreadPilot. +## Known Non-Goals -### Portable +- No anti-cheat bypass. +- No Windows Service. +- No registry or IFEO persistence. +- No generated release artifacts yet. +- No GitHub release or tag yet. -1. Download ThreadPilot_v1.1.6_singlefile_win-x64.zip. -2. Extract archive. -3. Run ThreadPilot.exe. +## Release Artifact Status -## Checksums - -See SHA256SUMS.txt in release assets. - -## Known Issues - -- Windows 10 support remains best effort. +- Installer, portable ZIP, checksums, package metadata verification, and release upload remain pending manual validation. diff --git a/docs/releases/v1.2.0-checklist.md b/docs/releases/v1.2.0-checklist.md new file mode 100644 index 0000000..cdaf394 --- /dev/null +++ b/docs/releases/v1.2.0-checklist.md @@ -0,0 +1,15 @@ +# ThreadPilot v1.2.0 Manual Validation Checklist + +- [ ] App starts without binding or startup errors. +- [ ] Process tab loads. +- [ ] Selected-process summary renders. +- [ ] Process tab context menu works. +- [ ] Affinity apply works on a normal single-group machine. +- [ ] CPU Sets clear works or fails safely with user-safe messaging. +- [ ] Memory priority set/read works or fails safely. +- [ ] Save as rule creates or updates a persistent process rule. +- [ ] Process-start auto-apply works while ThreadPilot is running. +- [ ] Protected or anti-cheat process operations fail safely without bypass language. +- [ ] Diagnostics is hidden by default. +- [ ] Startup does not enable performance monitoring or create performance-monitoring cost by default. +- [ ] Release artifacts are still pending manual validation. diff --git a/docs/releases/v1.2.0.md b/docs/releases/v1.2.0.md new file mode 100644 index 0000000..44e44a4 --- /dev/null +++ b/docs/releases/v1.2.0.md @@ -0,0 +1,44 @@ +# ThreadPilot v1.2.0 Release Notes Draft + +## Highlights + +- CPU topology v2: topology-aware `CpuSelection`, CPU Sets, processor groups, and safer handling above 64 logical processors. +- New safe affinity paths where CPU64 no longer aliases CPU0. +- Intel hybrid handling uses topology and `EfficiencyClass`; AMD preset generation is CCD/L3-aware. +- Memory priority support and persistent process rules. +- Saved rules can apply automatically when matching processes start while ThreadPilot is running. +- Process tab context menu actions, Save as rule, Apply now, and selected-process summary. +- Diagnostics remains optional and hidden by default. + +## Compatibility Notes + +- Requires Windows 11 build 22000 or newer. +- Administrator launch is still required for system-level process operations. +- Existing legacy affinity profiles remain supported. New safe paths prefer `CpuSelection` when topology data is available. +- CPU Sets and memory priority operations can fail on processes Windows denies access to; failures are reported safely. + +## Upgrade Notes for Legacy Profiles + +- Legacy affinity masks continue to load. +- When saving new rules from current Process tab settings, ThreadPilot attempts to store topology-aware `CpuSelection` when it can map the legacy selection safely. +- On systems where topology data is unavailable, ThreadPilot keeps the legacy affinity mask path instead of inventing an unsafe mapping. +- Users with old profiles should verify presets once on the target machine before relying on them for competitive or latency-sensitive workloads. + +## Limitations + +- Apply at process start is runtime-based and only works while ThreadPilot is running. +- ThreadPilot does not install a Windows Service for persistent rule application. +- ThreadPilot does not write registry or IFEO persistence for process rules. +- ThreadPilot does not bypass anti-cheat or protected-process restrictions. Administrator rights may help ordinary access-denied cases but do not override protected-process enforcement. + +## Known Non-Goals for This Draft + +- No anti-cheat bypass. +- No Windows Service. +- No registry or IFEO persistence. +- No generated release artifacts yet. +- No GitHub release or tag yet. + +## Release Artifact Status + +- Installer, portable ZIP, checksums, package metadata verification, and release upload remain pending manual validation. diff --git a/sonar-project.properties b/sonar-project.properties index 6613df3..c9fb7ea 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -1,6 +1,6 @@ sonar.projectKey=threadpilot sonar.projectName=ThreadPilot -sonar.projectVersion=1.1.3 +sonar.projectVersion=1.2.0 sonar.sourceEncoding=UTF-8 sonar.sources=. From 278dba5adb85c2f676df61e66d33089b03a8d086 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Fri, 22 May 2026 18:45:32 +0200 Subject: [PATCH 02/12] Polish v1.2.0 validation UX --- Services/IPowerPlanService.cs | 7 + Services/PowerPlanService.cs | 58 ++++++++ Services/SystemTrayMenuPlacement.cs | 17 +++ Services/SystemTrayService.cs | 17 ++- .../PerformanceViewModelDiagnosticsTests.cs | 57 ++++++- .../PowerPlanServiceTests.cs | 44 ++++++ .../PowerPlanViewModelTests.cs | 133 +++++++++++++++++ .../PowerPlanViewXamlTests.cs | 58 ++++++++ .../ProcessViewModelContextMenuTests.cs | 55 ++++++- .../ProcessViewXamlBindingTests.cs | 21 +++ .../SystemTrayPlacementHelperTests.cs | 28 ++++ ViewModels/PerformanceViewModel.cs | 16 +- ViewModels/PowerPlanViewModel.cs | 134 ++++++++++++----- .../ProcessViewModel.Behaviors.partial.cs | 63 +++++++- ViewModels/SettingsViewModel.cs | 10 +- ViewModels/SystemTweaksViewModel.cs | 17 ++- Views/PowerPlanView.xaml | 139 ++++++++++++------ Views/ProcessView.xaml | 111 +++++++++++++- 18 files changed, 884 insertions(+), 101 deletions(-) create mode 100644 Services/SystemTrayMenuPlacement.cs create mode 100644 Tests/ThreadPilot.Core.Tests/PowerPlanViewModelTests.cs create mode 100644 Tests/ThreadPilot.Core.Tests/PowerPlanViewXamlTests.cs create mode 100644 Tests/ThreadPilot.Core.Tests/SystemTrayPlacementHelperTests.cs diff --git a/Services/IPowerPlanService.cs b/Services/IPowerPlanService.cs index eee1621..6f50d0e 100644 --- a/Services/IPowerPlanService.cs +++ b/Services/IPowerPlanService.cs @@ -67,6 +67,13 @@ public interface IPowerPlanService /// when the file is added successfully; otherwise . Task AddCustomPowerPlanFileAsync(string filePath); + /// + /// Deletes a non-active Windows power plan by GUID when Windows permits removal. + /// + /// Power plan GUID to delete. + /// when deletion succeeds; otherwise . + Task DeletePowerPlanAsync(string powerPlanGuid); + /// /// Sets the active power plan by GUID with duplicate change prevention. /// diff --git a/Services/PowerPlanService.cs b/Services/PowerPlanService.cs index 5e1429a..af308f3 100644 --- a/Services/PowerPlanService.cs +++ b/Services/PowerPlanService.cs @@ -377,6 +377,64 @@ await this.enhancedLogger.LogErrorAsync(ex, "PowerPlanService.AddCustomPowerPlan } } + public async Task DeletePowerPlanAsync(string powerPlanGuid) + { + if (!Guid.TryParse(powerPlanGuid, out _)) + { + this.logger.LogWarning("Rejected invalid power plan GUID for delete: {PowerPlanGuid}", powerPlanGuid); + return false; + } + + try + { + var activePlan = await this.GetActivePowerPlan().ConfigureAwait(false); + if (string.Equals(activePlan?.Guid, powerPlanGuid, StringComparison.OrdinalIgnoreCase)) + { + this.logger.LogWarning("Blocked deletion of active power plan: {PowerPlanGuid}", powerPlanGuid); + await this.enhancedLogger.LogSystemEventAsync( + LogEventTypes.PowerPlan.ChangeFailed, + $"Blocked deletion of active power plan '{activePlan?.Name ?? powerPlanGuid}'", + Microsoft.Extensions.Logging.LogLevel.Warning).ConfigureAwait(false); + return false; + } + + var targetPlan = await this.GetPowerPlanByGuidAsync(powerPlanGuid).ConfigureAwait(false); + var result = await this.RunPowerCfgAsync("/delete", powerPlanGuid).ConfigureAwait(false); + var success = result.ExitCode == 0; + + if (success) + { + this.logger.LogInformation("Deleted power plan '{PowerPlan}' ({PowerPlanGuid})", targetPlan?.Name ?? "Unknown", powerPlanGuid); + await this.enhancedLogger.LogUserActionAsync( + "PowerPlanDeleted", + $"Deleted power plan {targetPlan?.Name ?? powerPlanGuid}", + $"Guid: {powerPlanGuid}").ConfigureAwait(false); + } + else + { + this.logger.LogWarning( + "Failed to delete power plan '{PowerPlanGuid}' - powercfg exit code: {ExitCode}, stderr: {StdErr}", + powerPlanGuid, + result.ExitCode, + result.StandardError); + + await this.enhancedLogger.LogSystemEventAsync( + LogEventTypes.PowerPlan.ChangeFailed, + $"Failed to delete power plan '{targetPlan?.Name ?? powerPlanGuid}' - Exit code: {result.ExitCode}", + Microsoft.Extensions.Logging.LogLevel.Warning).ConfigureAwait(false); + } + + return success; + } + catch (Exception ex) + { + this.logger.LogError(ex, "Exception occurred while deleting power plan '{PowerPlanGuid}'", powerPlanGuid); + await this.enhancedLogger.LogErrorAsync(ex, "PowerPlanService.DeletePowerPlanAsync", + new Dictionary { ["PowerPlanGuid"] = powerPlanGuid }).ConfigureAwait(false); + return false; + } + } + public async Task GetActivePowerPlanGuidAsync() { var activePlan = await this.GetActivePowerPlan().ConfigureAwait(false); diff --git a/Services/SystemTrayMenuPlacement.cs b/Services/SystemTrayMenuPlacement.cs new file mode 100644 index 0000000..ee3a7be --- /dev/null +++ b/Services/SystemTrayMenuPlacement.cs @@ -0,0 +1,17 @@ +namespace ThreadPilot.Services +{ + using System.Drawing; + + public static class SystemTrayMenuPlacement + { + public static Point ResolveMenuOpenPoint(Point cursorPosition, Point lastKnownPosition) + { + if (!cursorPosition.IsEmpty) + { + return cursorPosition; + } + + return lastKnownPosition.IsEmpty ? new Point(1, 1) : lastKnownPosition; + } + } +} diff --git a/Services/SystemTrayService.cs b/Services/SystemTrayService.cs index f21308c..f150985 100644 --- a/Services/SystemTrayService.cs +++ b/Services/SystemTrayService.cs @@ -46,6 +46,7 @@ public class SystemTrayService : ISystemTrayService private TrayIconState currentIconState = TrayIconState.Normal; private bool isDarkTheme = true; private Font? menuFont; + private Point lastContextMenuOpenPoint = Point.Empty; private bool disposed = false; public event EventHandler? QuickApplyRequested; @@ -100,7 +101,7 @@ public void Initialize() // Set up event handlers this.notifyIcon.DoubleClick += this.OnTrayIconDoubleClick; - this.notifyIcon.ContextMenuStrip = this.contextMenu; + this.notifyIcon.MouseUp += this.OnTrayIconMouseUp; // Set initial icon state this.UpdateTrayIcon(TrayIconState.Normal); @@ -255,6 +256,19 @@ private void OnTrayIconDoubleClick(object? sender, EventArgs e) this.ShowMainWindowRequested?.Invoke(this, EventArgs.Empty); } + private void OnTrayIconMouseUp(object? sender, MouseEventArgs e) + { + if (e.Button != MouseButtons.Right || this.contextMenu == null) + { + return; + } + + var openPoint = SystemTrayMenuPlacement.ResolveMenuOpenPoint(Cursor.Position, this.lastContextMenuOpenPoint); + this.lastContextMenuOpenPoint = openPoint; + + this.contextMenu.Show(openPoint); + } + private void OnQuickApplyClick(object? sender, EventArgs e) { this.QuickApplyRequested?.Invoke(this, EventArgs.Empty); @@ -513,6 +527,7 @@ public void Dispose() if (this.notifyIcon != null) { + this.notifyIcon.MouseUp -= this.OnTrayIconMouseUp; this.notifyIcon.Visible = false; this.notifyIcon.Dispose(); this.notifyIcon = null; diff --git a/Tests/ThreadPilot.Core.Tests/PerformanceViewModelDiagnosticsTests.cs b/Tests/ThreadPilot.Core.Tests/PerformanceViewModelDiagnosticsTests.cs index 3ba733c..f3e9e87 100644 --- a/Tests/ThreadPilot.Core.Tests/PerformanceViewModelDiagnosticsTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PerformanceViewModelDiagnosticsTests.cs @@ -56,6 +56,39 @@ public async Task SuspendBackgroundMonitoringAsync_StopsLiveMonitoringAndDoesNot Assert.Equal("Stopped", viewModel.MonitoringStateText); } + [Fact] + public async Task StartMonitoringCommand_LogsSuccess_WhenServiceStarts() + { + var harness = new Harness(); + var viewModel = harness.CreateViewModel(); + + await viewModel.StartMonitoringCommand.ExecuteAsync(null); + + harness.Logging.Verify( + logger => logger.LogUserActionAsync( + "OptimizationMonitoringStarted", + "Performance monitoring started", + null), + Times.Once); + } + + [Fact] + public async Task StartMonitoringCommand_LogsFailure_WhenServiceFails() + { + var harness = new Harness(startMonitoringThrows: true); + var viewModel = harness.CreateViewModel(); + + await viewModel.StartMonitoringCommand.ExecuteAsync(null); + + Assert.True(viewModel.HasError); + harness.Logging.Verify( + logger => logger.LogUserActionAsync( + "OptimizationActionFailed", + It.Is(details => details.Contains("Failed to start performance monitoring")), + null), + Times.Once); + } + [Fact] public void ShowAdvancedDiagnostics_DefaultsToHidden() { @@ -76,7 +109,9 @@ private sealed class Harness public Mock SystemTweaks { get; } = new(MockBehavior.Strict); - public Harness() + public Mock Logging { get; } = new(MockBehavior.Loose); + + public Harness(bool startMonitoringThrows = false) { this.Performance .Setup(x => x.GetSystemMetricsAsync(It.IsAny())) @@ -90,11 +125,24 @@ public Harness() this.Performance .Setup(x => x.GetTopMemoryProcessesAsync(It.IsAny())) .ReturnsAsync(new List()); + if (startMonitoringThrows) + { + this.Performance + .Setup(x => x.StartMonitoringAsync()) + .ThrowsAsync(new InvalidOperationException("monitoring unavailable")); + } + else + { + this.Performance + .Setup(x => x.StartMonitoringAsync()) + .Returns(Task.CompletedTask); + } + this.Performance - .Setup(x => x.StartMonitoringAsync()) + .Setup(x => x.StopMonitoringAsync()) .Returns(Task.CompletedTask); this.Performance - .Setup(x => x.StopMonitoringAsync()) + .Setup(x => x.ClearHistoricalDataAsync()) .Returns(Task.CompletedTask); this.Associations @@ -114,7 +162,8 @@ public PerformanceViewModel CreateViewModel() => this.PowerPlan.Object, this.ProcessMonitorManager.Object, this.SystemTweaks.Object, - NullLogger.Instance); + NullLogger.Instance, + this.Logging.Object); } } } diff --git a/Tests/ThreadPilot.Core.Tests/PowerPlanServiceTests.cs b/Tests/ThreadPilot.Core.Tests/PowerPlanServiceTests.cs index d7610fe..1c08dc2 100644 --- a/Tests/ThreadPilot.Core.Tests/PowerPlanServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PowerPlanServiceTests.cs @@ -56,6 +56,50 @@ public async Task SetActivePowerPlanByGuidAsync_SkipsChange_WhenAlreadyActive() Assert.Equal(new[] { "/getactivescheme" }, invocation.Arguments); } + [Fact] + public async Task DeletePowerPlanAsync_InvokesPowerCfgDelete_WhenPlanIsNotActive() + { + const string activeGuid = "381b4222-f694-41f0-9685-ff5bb260df2e"; + const string deleteGuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + var runner = new RecordingProcessRunner + { + ResultFactory = invocation => + invocation.Arguments.SequenceEqual(new[] { "/getactivescheme" }) + ? new ProcessRunResult( + 0, + $"Power Scheme GUID: {activeGuid} (Balanced)", + string.Empty) + : new ProcessRunResult(0, string.Empty, string.Empty), + }; + var service = CreateService(runner); + + var result = await service.DeletePowerPlanAsync(deleteGuid); + + Assert.True(result); + Assert.Contains(runner.Invocations, invocation => + invocation.Arguments.SequenceEqual(new[] { "/delete", deleteGuid })); + } + + [Fact] + public async Task DeletePowerPlanAsync_DoesNotDeleteActivePlan() + { + const string activeGuid = "381b4222-f694-41f0-9685-ff5bb260df2e"; + var runner = new RecordingProcessRunner + { + ResultFactory = _ => new ProcessRunResult( + 0, + $"Power Scheme GUID: {activeGuid} (Balanced)", + string.Empty), + }; + var service = CreateService(runner); + + var result = await service.DeletePowerPlanAsync(activeGuid); + + Assert.False(result); + Assert.DoesNotContain(runner.Invocations, invocation => + invocation.Arguments.Contains("/delete", StringComparer.OrdinalIgnoreCase)); + } + [Fact] public async Task AddCustomPowerPlanFileAsync_RenamesOnCollision() { diff --git a/Tests/ThreadPilot.Core.Tests/PowerPlanViewModelTests.cs b/Tests/ThreadPilot.Core.Tests/PowerPlanViewModelTests.cs new file mode 100644 index 0000000..1ec4b15 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/PowerPlanViewModelTests.cs @@ -0,0 +1,133 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Collections.ObjectModel; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + + public sealed class PowerPlanViewModelTests + { + [Fact] + public async Task DeletePowerPlanCommand_CallsServiceRefreshesAndLogs_WhenPlanIsNotActive() + { + var harness = new Harness(); + var deletePlan = new PowerPlanModel { Guid = Harness.DeleteGuid, Name = "Gaming" }; + var viewModel = harness.CreateViewModel(); + + await viewModel.DeletePowerPlanCommand.ExecuteAsync(deletePlan); + + harness.PowerPlan.Verify(service => service.DeletePowerPlanAsync(Harness.DeleteGuid), Times.Once); + harness.PowerPlan.Verify(service => service.GetPowerPlansAsync(), Times.Once); + harness.Logging.Verify( + logger => logger.LogUserActionAsync( + "PowerPlanDeleted", + "Deleted power plan Gaming", + $"Guid: {Harness.DeleteGuid}"), + Times.Once); + Assert.Equal("Power plan deleted: Gaming.", viewModel.StatusMessage); + Assert.False(viewModel.HasError); + } + + [Fact] + public async Task DeletePowerPlanCommand_BlocksActivePlanBeforeCallingService() + { + var harness = new Harness(); + var activePlan = new PowerPlanModel { Guid = Harness.ActiveGuid, Name = "Balanced", IsActive = true }; + var viewModel = harness.CreateViewModel(); + + await viewModel.DeletePowerPlanCommand.ExecuteAsync(activePlan); + + harness.PowerPlan.Verify(service => service.DeletePowerPlanAsync(It.IsAny()), Times.Never); + Assert.Equal("Switch to another power plan before deleting the active plan.", viewModel.StatusMessage); + Assert.True(viewModel.HasError); + } + + [Fact] + public async Task DeletePowerPlanCommand_ShowsFailureAndDoesNotCrash_WhenServiceFails() + { + var harness = new Harness(deleteSucceeds: false); + var deletePlan = new PowerPlanModel { Guid = Harness.DeleteGuid, Name = "Gaming" }; + var viewModel = harness.CreateViewModel(); + + await viewModel.DeletePowerPlanCommand.ExecuteAsync(deletePlan); + + Assert.Equal("Could not delete power plan Gaming. Windows may not allow this plan to be removed.", viewModel.StatusMessage); + Assert.True(viewModel.HasError); + } + + [Fact] + public async Task SetActivePlanCommand_ShowsSuccessStatusAndLogs() + { + var harness = new Harness(); + var viewModel = harness.CreateViewModel(); + viewModel.SelectedPowerPlan = new PowerPlanModel { Guid = Harness.DeleteGuid, Name = "Gaming" }; + + await viewModel.SetActivePlanCommand.ExecuteAsync(null); + + Assert.Equal("Power plan applied: Gaming.", viewModel.StatusMessage); + harness.Logging.Verify( + logger => logger.LogUserActionAsync( + "PowerPlanApplied", + "Applied power plan Gaming", + $"Guid: {Harness.DeleteGuid}"), + Times.Once); + } + + [Fact] + public async Task RefreshPowerPlansCommand_ShowsCompletionStatusAndLogs() + { + var harness = new Harness(); + var viewModel = harness.CreateViewModel(); + + await viewModel.RefreshPowerPlansCommand.ExecuteAsync(null); + + Assert.Equal("Power plans refreshed.", viewModel.StatusMessage); + harness.Logging.Verify( + logger => logger.LogUserActionAsync( + "PowerPlansRefreshed", + "Refreshed power plan list", + null), + Times.Once); + } + + private sealed class Harness + { + public const string ActiveGuid = "381b4222-f694-41f0-9685-ff5bb260df2e"; + public const string DeleteGuid = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"; + + public Mock PowerPlan { get; } = new(MockBehavior.Strict); + + public Mock Logging { get; } = new(MockBehavior.Loose); + + public Harness(bool deleteSucceeds = true) + { + var active = new PowerPlanModel { Guid = ActiveGuid, Name = "Balanced", IsActive = true }; + var delete = new PowerPlanModel { Guid = DeleteGuid, Name = "Gaming" }; + + this.PowerPlan + .Setup(service => service.GetPowerPlansAsync()) + .ReturnsAsync(new ObservableCollection { active, delete }); + this.PowerPlan + .Setup(service => service.GetCustomPowerPlansAsync()) + .ReturnsAsync(new ObservableCollection()); + this.PowerPlan + .Setup(service => service.GetActivePowerPlan()) + .ReturnsAsync(active); + this.PowerPlan + .Setup(service => service.SetActivePowerPlan(It.IsAny())) + .ReturnsAsync(true); + this.PowerPlan + .Setup(service => service.DeletePowerPlanAsync(DeleteGuid)) + .ReturnsAsync(deleteSucceeds); + } + + public PowerPlanViewModel CreateViewModel() => + new( + NullLogger.Instance, + this.PowerPlan.Object, + this.Logging.Object); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/PowerPlanViewXamlTests.cs b/Tests/ThreadPilot.Core.Tests/PowerPlanViewXamlTests.cs new file mode 100644 index 0000000..0b9f5f7 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/PowerPlanViewXamlTests.cs @@ -0,0 +1,58 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Xml.Linq; + + public sealed class PowerPlanViewXamlTests + { + private static readonly string PowerPlanViewPath = Path.Combine( + AppContext.BaseDirectory, + "..", + "..", + "..", + "..", + "..", + "Views", + "PowerPlanView.xaml"); + + [Fact] + public void PowerPlanItems_ExposeDeleteContextMenu() + { + var document = XDocument.Load(PowerPlanViewPath, LoadOptions.PreserveWhitespace); + var deleteCommandBinding = document + .Descendants() + .SelectMany(element => element.Attributes()) + .SingleOrDefault(attribute => attribute.Value.Contains("DeletePowerPlanCommand", StringComparison.Ordinal)); + + Assert.NotNull(deleteCommandBinding); + } + + [Fact] + public void HeaderInstructionText_WrapsToAvoidButtonOverlap() + { + var document = XDocument.Load(PowerPlanViewPath, LoadOptions.PreserveWhitespace); + var instructionTextBlocks = document + .Descendants() + .Where(element => element.Name.LocalName == "TextBlock") + .Where(element => element.Attributes().Any(attribute => + attribute.Value.Contains("Select the Windows plan", StringComparison.Ordinal) || + attribute.Value.Contains("Local .pow files", StringComparison.Ordinal))) + .ToList(); + + Assert.Equal(2, instructionTextBlocks.Count); + Assert.All(instructionTextBlocks, textBlock => + Assert.Contains(textBlock.Attributes(), attribute => + attribute.Name.LocalName == "TextWrapping" && attribute.Value == "Wrap")); + } + + [Fact] + public void ActivePowerPlanTemplate_ContainsActiveBadgeAndAccentBorder() + { + var document = XDocument.Load(PowerPlanViewPath, LoadOptions.PreserveWhitespace); + var serialized = document.ToString(SaveOptions.DisableFormatting); + + Assert.Contains("Active", serialized, StringComparison.Ordinal); + Assert.Contains("IsActive", serialized, StringComparison.Ordinal); + Assert.Contains("Accent", serialized, StringComparison.Ordinal); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs index c591090..1f6d3f0 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs @@ -14,7 +14,10 @@ public sealed class ProcessViewModelContextMenuTests public async Task ContextCpuPriorityCommand_CallsSafePriorityServicePath() { var processService = CreateProcessService(); - var viewModel = CreateViewModel(processService.Object); + var enhancedLoggingService = new Mock(MockBehavior.Loose); + var viewModel = CreateViewModel( + processService.Object, + enhancedLoggingService: enhancedLoggingService.Object); var process = CreateProcess(priority: ProcessPriorityClass.Normal); await viewModel.SetContextHighPriorityCommand.ExecuteAsync(process); @@ -22,6 +25,12 @@ public async Task ContextCpuPriorityCommand_CallsSafePriorityServicePath() processService.Verify( service => service.SetProcessPriority(process, ProcessPriorityClass.High), Times.Once); + enhancedLoggingService.Verify( + service => service.LogUserActionAsync( + "ProcessPriorityChanged", + It.Is(details => details.Contains("Game.exe") && details.Contains("High")), + It.Is(context => context.Contains("PID: 42"))), + Times.Once); Assert.Equal(ProcessOperationUserMessages.HighPriorityWarning, viewModel.StatusMessage); Assert.False(viewModel.HasError); } @@ -31,9 +40,11 @@ public async Task ApplyContextAffinityCommand_UsesProvidedRowProcess() { var processService = CreateProcessService(); var coordinator = CreateAffinityCoordinator(); + var enhancedLoggingService = new Mock(MockBehavior.Loose); var viewModel = CreateViewModel( processService.Object, - processAffinityApplyCoordinator: coordinator.Object); + processAffinityApplyCoordinator: coordinator.Object, + enhancedLoggingService: enhancedLoggingService.Object); viewModel.CpuCores = [ new CpuCoreModel { LogicalCoreId = 0, IsSelected = true }, @@ -50,6 +61,12 @@ public async Task ApplyContextAffinityCommand_UsesProvidedRowProcess() "Manual Process tab context menu CPU selection", default), Times.Once); + enhancedLoggingService.Verify( + service => service.LogUserActionAsync( + "ProcessAffinityApplied", + It.IsAny(), + It.Is(context => context.Contains("Process: Game.exe") && context.Contains("PID: 100"))), + Times.Once); Assert.Same(rowProcess, viewModel.SelectedProcess); } @@ -136,16 +153,24 @@ public async Task ContextMemoryPriorityCommand_CallsMemoryPriorityService() memoryPriorityService .Setup(service => service.GetMemoryPriorityAsync(It.IsAny())) .ReturnsAsync(ProcessMemoryPriority.Low); + var enhancedLoggingService = new Mock(MockBehavior.Loose); var process = CreateProcess(); var viewModel = CreateViewModel( CreateProcessService().Object, - memoryPriorityService: memoryPriorityService.Object); + memoryPriorityService: memoryPriorityService.Object, + enhancedLoggingService: enhancedLoggingService.Object); await viewModel.SetContextMemoryPriorityLowCommand.ExecuteAsync(process); memoryPriorityService.Verify( service => service.SetMemoryPriorityAsync(process, ProcessMemoryPriority.Low), Times.Once); + enhancedLoggingService.Verify( + service => service.LogUserActionAsync( + "ProcessMemoryPriorityChanged", + It.Is(details => details.Contains("Game.exe") && details.Contains("Low")), + It.Is(context => context.Contains("PID: 42"))), + Times.Once); } [Fact] @@ -298,10 +323,12 @@ public async Task ContextMenuActions_DoNotCreatePersistentRules() public async Task SaveCurrentSettingsAsRuleCommand_CreatesRuleForSelectedProcess() { var ruleStore = new CapturingRuleStore(); + var enhancedLoggingService = new Mock(MockBehavior.Loose); var viewModel = CreateViewModel( CreateProcessService().Object, persistentRuleStore: ruleStore, - processRuleCreationService: CreateRuleCreationService(ruleStore)); + processRuleCreationService: CreateRuleCreationService(ruleStore), + enhancedLoggingService: enhancedLoggingService.Object); var process = CreateProcess(); viewModel.SelectedProcess = process; viewModel.CpuCores = @@ -316,6 +343,12 @@ public async Task SaveCurrentSettingsAsRuleCommand_CreatesRuleForSelectedProcess Assert.Equal(process.Name, rule.ProcessName); Assert.Equal(process.ExecutablePath, rule.ExecutablePath); Assert.Equal("Saved rule for Game.exe.", viewModel.StatusMessage); + enhancedLoggingService.Verify( + service => service.LogUserActionAsync( + "PersistentRuleSaved", + "Saved rule for Game.exe.", + It.Is(context => context.Contains("Process: Game.exe") && context.Contains("PID: 42"))), + Times.Once); } [Fact] @@ -332,10 +365,12 @@ public async Task SaveCurrentSettingsAsRuleCommand_UpdatesExistingMatchingRule() UpdatedAt = DateTime.UtcNow.AddDays(-1), }; var ruleStore = new CapturingRuleStore([existing]); + var enhancedLoggingService = new Mock(MockBehavior.Loose); var viewModel = CreateViewModel( CreateProcessService().Object, persistentRuleStore: ruleStore, - processRuleCreationService: CreateRuleCreationService(ruleStore)); + processRuleCreationService: CreateRuleCreationService(ruleStore), + enhancedLoggingService: enhancedLoggingService.Object); await viewModel.SaveCurrentSettingsAsRuleCommand.ExecuteAsync(CreateProcess(priority: ProcessPriorityClass.High)); @@ -343,6 +378,12 @@ public async Task SaveCurrentSettingsAsRuleCommand_UpdatesExistingMatchingRule() Assert.Equal("rule-1", rule.Id); Assert.Equal(ProcessPriorityClass.High, rule.Priority); Assert.Equal("Updated saved rule for Game.exe.", viewModel.StatusMessage); + enhancedLoggingService.Verify( + service => service.LogUserActionAsync( + "PersistentRuleSaved", + "Updated saved rule for Game.exe.", + It.Is(context => context.Contains("Process: Game.exe") && context.Contains("PID: 42"))), + Times.Once); } [Fact] @@ -530,7 +571,8 @@ private static ProcessViewModel CreateViewModel( IPersistentProcessRuleStore? persistentRuleStore = null, IProcessRuleCreationService? processRuleCreationService = null, Action? clipboardSetter = null, - Action? executableLocationOpener = null) + Action? executableLocationOpener = null, + IEnhancedLoggingService? enhancedLoggingService = null) { var virtualizedProcessService = new Mock(MockBehavior.Loose); virtualizedProcessService.SetupProperty( @@ -558,6 +600,7 @@ private static ProcessViewModel CreateViewModel( associationService.Object, gameModeService.Object, processAffinityApplyCoordinator: processAffinityApplyCoordinator, + enhancedLoggingService: enhancedLoggingService, memoryPriorityService: memoryPriorityService, persistentRuleStore: persistentRuleStore, persistentRuleMatcher: new PersistentProcessRuleMatcher(), diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs index f54d0d8..6281eeb 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs @@ -63,5 +63,26 @@ public void SelectedProcessSummaryBindings_AreNotUsedByEditableControls() Assert.Empty(editableSummaryBindings); } + + [Fact] + public void ProcessGridRowStyle_HighlightsSelectedRowsWithAccentTheme() + { + var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); + var serialized = document.ToString(SaveOptions.DisableFormatting); + + Assert.Contains("IsSelected", serialized, StringComparison.Ordinal); + Assert.Contains("Accent", serialized, StringComparison.Ordinal); + Assert.Contains("BorderThickness", serialized, StringComparison.Ordinal); + } + + [Fact] + public void LegacyActionSidePanel_IsNotPersistentPrimaryUi() + { + var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); + var serialized = document.ToString(SaveOptions.DisableFormatting); + + Assert.Contains("Grid.Column=\"2\" Visibility=\"Collapsed\"", serialized, StringComparison.Ordinal); + Assert.Contains("Advanced affinity picker", serialized, StringComparison.Ordinal); + } } } diff --git a/Tests/ThreadPilot.Core.Tests/SystemTrayPlacementHelperTests.cs b/Tests/ThreadPilot.Core.Tests/SystemTrayPlacementHelperTests.cs new file mode 100644 index 0000000..a19b19f --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/SystemTrayPlacementHelperTests.cs @@ -0,0 +1,28 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Drawing; + using ThreadPilot.Services; + + public sealed class SystemTrayPlacementHelperTests + { + [Fact] + public void ResolveMenuOpenPoint_UsesCursorPositionOnFirstOpen() + { + var cursor = new Point(1200, 700); + + var result = SystemTrayMenuPlacement.ResolveMenuOpenPoint(cursor, Point.Empty); + + Assert.Equal(cursor, result); + } + + [Fact] + public void ResolveMenuOpenPoint_FallsBackToLastKnownPoint_WhenCursorIsUnavailable() + { + var lastKnown = new Point(1600, 900); + + var result = SystemTrayMenuPlacement.ResolveMenuOpenPoint(Point.Empty, lastKnown); + + Assert.Equal(lastKnown, result); + } + } +} diff --git a/ViewModels/PerformanceViewModel.cs b/ViewModels/PerformanceViewModel.cs index b0a3f3b..4119fef 100644 --- a/ViewModels/PerformanceViewModel.cs +++ b/ViewModels/PerformanceViewModel.cs @@ -177,8 +177,9 @@ public PerformanceViewModel( IPowerPlanService powerPlanService, IProcessMonitorManagerService processMonitorManagerService, ISystemTweaksService systemTweaksService, - ILogger logger) - : base(logger, null) + ILogger logger, + IEnhancedLoggingService? enhancedLoggingService = null) + : base(logger, enhancedLoggingService) { this.performanceService = performanceService; this.processService = processService; @@ -263,10 +264,12 @@ private async Task StartMonitoringAsync() this.AddTimelineEvent("Live Metrics", "Live metrics started.", "Info"); this.SetStatus("Performance monitoring started", false); + await this.LogUserActionAsync("OptimizationMonitoringStarted", "Performance monitoring started"); } catch (Exception ex) { this.SetError("Failed to start performance monitoring", ex); + await this.LogUserActionAsync("OptimizationActionFailed", $"Failed to start performance monitoring: {ex.Message}"); } } @@ -284,10 +287,12 @@ private async Task StopMonitoringAsync() this.AddTimelineEvent("Live Metrics", "Live metrics stopped.", "Warning"); this.SetStatus("Performance monitoring stopped", false); + await this.LogUserActionAsync("OptimizationMonitoringStopped", "Performance monitoring stopped"); } catch (Exception ex) { this.SetError("Failed to stop performance monitoring", ex); + await this.LogUserActionAsync("OptimizationActionFailed", $"Failed to stop performance monitoring: {ex.Message}"); } } @@ -333,10 +338,12 @@ private async Task RefreshMetricsSnapshotAsync() this.LastManualRefreshText = $"Refreshed at {DateTime.Now:HH:mm:ss}"; this.SetStatus("Performance snapshot refreshed", false); + await this.LogUserActionAsync("OptimizationSnapshotRefreshed", "Performance snapshot refreshed"); } catch (Exception ex) { this.SetError("Failed to refresh performance snapshot", ex); + await this.LogUserActionAsync("OptimizationActionFailed", $"Failed to refresh performance snapshot: {ex.Message}"); } } @@ -350,10 +357,12 @@ private async Task ClearHistoricalDataAsync() this.UpdateTimelineSummary(); this.AddTimelineEvent("History", "Historical metrics cleared.", "Info"); this.SetStatus("Historical data cleared", false); + await this.LogUserActionAsync("OptimizationHistoryCleared", "Historical metrics cleared"); } catch (Exception ex) { this.SetError("Failed to clear historical data", ex); + await this.LogUserActionAsync("OptimizationActionFailed", $"Failed to clear historical data: {ex.Message}"); } } @@ -430,6 +439,7 @@ private async Task CreateRuleFromSelectedProcessAsync() this.AddTimelineEvent("Rule", $"Rule created for {executableName} from hotspot panel.", "Success"); this.SetStatus($"Rule created for {executableName} and ready for automation.", false); + await this.LogUserActionAsync("PersistentRuleSaved", $"Rule created for {executableName} from hotspot panel"); } else { @@ -451,6 +461,7 @@ private async Task CreateRuleFromSelectedProcessAsync() this.AddTimelineEvent("Rule", $"Rule updated for {executableName} from hotspot panel.", "Success"); this.SetStatus($"Rule updated for {executableName} from hotspot panel.", false); + await this.LogUserActionAsync("PersistentRuleUpdated", $"Rule updated for {executableName} from hotspot panel"); } await this.RefreshSelectedProcessRuleImpactAsync(); @@ -460,6 +471,7 @@ private async Task CreateRuleFromSelectedProcessAsync() { this.logger.LogError(ex, "Failed to create or update rule from performance hotspot"); this.SetError("Failed to create rule from selected hotspot", ex); + await this.LogUserActionAsync("PersistentRuleSaveFailed", $"Failed to create rule from selected hotspot: {ex.Message}"); } finally { diff --git a/ViewModels/PowerPlanViewModel.cs b/ViewModels/PowerPlanViewModel.cs index 39e5871..813aa79 100644 --- a/ViewModels/PowerPlanViewModel.cs +++ b/ViewModels/PowerPlanViewModel.cs @@ -135,14 +135,12 @@ public async Task LoadPowerPlans() try { this.SetStatus("Loading power plans..."); - this.PowerPlans = await this.powerPlanService.GetPowerPlansAsync(); - this.CustomPowerPlans = await this.powerPlanService.GetCustomPowerPlansAsync(); - this.ActivePowerPlan = await this.powerPlanService.GetActivePowerPlan(); - this.ClearStatus(); + await this.RefreshPowerPlansCoreAsync(reportStatus: false); + this.SetStatus("Power plans loaded.", false); } catch (Exception ex) { - this.SetStatus($"Error loading power plans: {ex.Message}", false); + await this.SetOperationFailedAsync($"Error loading power plans: {ex.Message}", "PowerPlanLoadFailed"); } } @@ -156,24 +154,11 @@ private async Task RefreshPowerPlans() try { - var currentPlans = await this.powerPlanService.GetPowerPlansAsync(); - var currentActive = await this.powerPlanService.GetActivePowerPlan(); - var customPlans = await this.powerPlanService.GetCustomPowerPlansAsync(); - - // Update power plans - this.PowerPlans = new ObservableCollection(currentPlans); - this.CustomPowerPlans = new ObservableCollection(customPlans); - this.ActivePowerPlan = currentActive; - - // Update selected plan if it exists - if (this.SelectedPowerPlan != null) - { - this.SelectedPowerPlan = this.PowerPlans.FirstOrDefault(p => p.Guid == this.SelectedPowerPlan.Guid); - } + await this.RefreshPowerPlansCoreAsync(reportStatus: true); } catch (Exception ex) { - this.SetStatus($"Error refreshing power plans: {ex.Message}", false); + await this.SetOperationFailedAsync($"Error refreshing power plans: {ex.Message}", "PowerPlanRefreshFailed"); } } @@ -187,23 +172,25 @@ private async Task SetActivePlan() try { - this.SetStatus($"Setting active power plan to {this.SelectedPowerPlan.Name}..."); - var success = await this.powerPlanService.SetActivePowerPlan(this.SelectedPowerPlan); + var targetPlan = this.SelectedPowerPlan; + this.SetStatus($"Setting active power plan to {targetPlan.Name}..."); + var success = await this.powerPlanService.SetActivePowerPlan(targetPlan); if (success) { - this.ActivePowerPlan = this.SelectedPowerPlan; - await this.RefreshPowerPlans(); - this.ClearStatus(); + this.ActivePowerPlan = targetPlan; + await this.RefreshPowerPlansCoreAsync(reportStatus: false); + this.SetStatus($"Power plan applied: {targetPlan.Name}.", false); + await this.LogUserActionAsync("PowerPlanApplied", $"Applied power plan {targetPlan.Name}", $"Guid: {targetPlan.Guid}"); } else { - this.SetStatus($"Failed to set power plan {this.SelectedPowerPlan.Name}", false); + await this.SetOperationFailedAsync($"Failed to set power plan {targetPlan.Name}", "PowerPlanApplyFailed"); } } catch (Exception ex) { - this.SetStatus($"Error setting power plan: {ex.Message}", false); + await this.SetOperationFailedAsync($"Error setting power plan: {ex.Message}", "PowerPlanApplyFailed"); } } @@ -217,22 +204,24 @@ private async Task ImportCustomPlan() try { - this.SetStatus($"Importing custom power plan {this.SelectedCustomPlan.Name}..."); - var success = await this.powerPlanService.ImportCustomPowerPlan(this.SelectedCustomPlan.FilePath); + var customPlan = this.SelectedCustomPlan; + this.SetStatus($"Importing custom power plan {customPlan.Name}..."); + var success = await this.powerPlanService.ImportCustomPowerPlan(customPlan.FilePath); if (success) { - await this.RefreshPowerPlans(); - this.ClearStatus(); + await this.RefreshPowerPlansCoreAsync(reportStatus: false); + this.SetStatus($"Power plan imported: {customPlan.Name}.", false); + await this.LogUserActionAsync("PowerPlanImported", $"Imported power plan {customPlan.Name}", customPlan.FilePath); } else { - this.SetStatus($"Failed to import power plan {this.SelectedCustomPlan.Name}", false); + await this.SetOperationFailedAsync($"Failed to import power plan {customPlan.Name}", "PowerPlanImportFailed"); } } catch (Exception ex) { - this.SetStatus($"Error importing power plan: {ex.Message}", false); + await this.SetOperationFailedAsync($"Error importing power plan: {ex.Message}", "PowerPlanImportFailed"); } } @@ -261,18 +250,91 @@ private async Task AddCustomPlanFile() if (success) { - await this.RefreshPowerPlans(); + await this.RefreshPowerPlansCoreAsync(reportStatus: false); this.SetStatus("Custom power plan added to library.", false); + await this.LogUserActionAsync("PowerPlanAdded", "Added custom power plan file", dialog.FileName); } else { - this.SetStatus("Failed to add custom power plan file.", false); + await this.SetOperationFailedAsync("Failed to add custom power plan file.", "PowerPlanAddFailed"); + } + } + catch (Exception ex) + { + await this.SetOperationFailedAsync($"Error adding custom power plan file: {ex.Message}", "PowerPlanAddFailed"); + } + } + + [RelayCommand] + private async Task DeletePowerPlan(PowerPlanModel? powerPlan) + { + var targetPlan = powerPlan ?? this.SelectedPowerPlan; + if (targetPlan == null) + { + return; + } + + var activePlan = this.ActivePowerPlan ?? await this.powerPlanService.GetActivePowerPlan(); + if (targetPlan.IsActive || string.Equals(targetPlan.Guid, activePlan?.Guid, StringComparison.OrdinalIgnoreCase)) + { + await this.SetOperationFailedAsync("Switch to another power plan before deleting the active plan.", "PowerPlanDeleteBlocked"); + return; + } + + try + { + this.SetStatus($"Deleting power plan {targetPlan.Name}..."); + var success = await this.powerPlanService.DeletePowerPlanAsync(targetPlan.Guid); + if (!success) + { + await this.SetOperationFailedAsync( + $"Could not delete power plan {targetPlan.Name}. Windows may not allow this plan to be removed.", + "PowerPlanDeleteFailed"); + return; } + + await this.RefreshPowerPlansCoreAsync(reportStatus: false); + this.SetStatus($"Power plan deleted: {targetPlan.Name}.", false); + await this.LogUserActionAsync("PowerPlanDeleted", $"Deleted power plan {targetPlan.Name}", $"Guid: {targetPlan.Guid}"); } catch (Exception ex) { - this.SetStatus($"Error adding custom power plan file: {ex.Message}", false); + await this.SetOperationFailedAsync($"Error deleting power plan: {ex.Message}", "PowerPlanDeleteFailed"); } } + + private async Task RefreshPowerPlansCoreAsync(bool reportStatus) + { + var currentPlans = await this.powerPlanService.GetPowerPlansAsync(); + var currentActive = await this.powerPlanService.GetActivePowerPlan(); + var customPlans = await this.powerPlanService.GetCustomPowerPlansAsync(); + + this.PowerPlans = new ObservableCollection(currentPlans); + this.CustomPowerPlans = new ObservableCollection(customPlans); + this.ActivePowerPlan = currentActive; + + foreach (var plan in this.PowerPlans) + { + plan.IsActive = string.Equals(plan.Guid, currentActive?.Guid, StringComparison.OrdinalIgnoreCase); + } + + if (this.SelectedPowerPlan != null) + { + this.SelectedPowerPlan = this.PowerPlans.FirstOrDefault(p => p.Guid == this.SelectedPowerPlan.Guid); + } + + if (reportStatus) + { + this.SetStatus("Power plans refreshed.", false); + await this.LogUserActionAsync("PowerPlansRefreshed", "Refreshed power plan list"); + } + } + + private async Task SetOperationFailedAsync(string message, string action) + { + this.SetStatus(message, false); + this.SetError(message); + await this.LogUserActionAsync(action, message); + } } } diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs index 5f16e95..a047801 100644 --- a/ViewModels/ProcessViewModel.Behaviors.partial.cs +++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs @@ -779,6 +779,10 @@ private async Task SaveCurrentSettingsAsRule(ProcessModel? process) this.SelectedProcessSummary.MemoryPriority); this.ApplyRuleCreationResultStatus(result); + await this.LogUserActionAsync( + result.Success ? "PersistentRuleSaved" : "PersistentRuleSaveFailed", + result.UserMessage, + $"Process: {targetProcess.Name}, PID: {targetProcess.ProcessId}"); await this.UpdateSelectedProcessSummaryAsync(targetProcess); } @@ -840,6 +844,10 @@ private async Task ApplyAffinityAndSaveAsRule(ProcessModel? process) }); this.ApplyRuleCreationResultStatus(saveResult); + await this.LogUserActionAsync( + saveResult.Success ? "PersistentRuleSaved" : "PersistentRuleSaveFailed", + saveResult.UserMessage, + $"Process: {process.Name}, PID: {process.ProcessId}"); await this.UpdateSelectedProcessSummaryAsync(process); } @@ -920,12 +928,20 @@ await InvokeOnUiAsync(() => } }); + await this.LogUserActionAsync( + result.Success ? "ProcessAffinityApplied" : "ProcessAffinityFailed", + result.Message, + $"Process: {selectedProcess.Name}, PID: {selectedProcess.ProcessId}, RequestedMask: 0x{result.RequestedMask:X}, VerifiedMask: 0x{result.VerifiedMask:X}"); await this.UpdateSelectedProcessSummaryAsync(selectedProcess); } catch (Exception ex) { var friendly = ex.Message; _ = this.notificationService.ShowNotificationAsync("Affinity error", friendly, NotificationType.Error); + await this.LogUserActionAsync( + "ProcessAffinityFailed", + friendly, + $"Process: {selectedProcess.Name}, PID: {selectedProcess.ProcessId}"); await InvokeOnUiAsync(() => { @@ -1457,17 +1473,30 @@ private async Task ClearContextCpuSets(ProcessModel? process) if (!success) { this.SetContextError(ProcessOperationUserMessages.AccessDenied); + await this.LogUserActionAsync( + "CpuSetsClearFailed", + ProcessOperationUserMessages.AccessDenied, + $"Process: {process.Name}, PID: {process.ProcessId}"); await this.UpdateSelectedProcessSummaryAsync(process); return; } await this.processService.RefreshProcessInfo(process); this.SetStatus($"CPU Sets cleared for {process.Name}.", false); + await this.LogUserActionAsync( + "CpuSetsCleared", + $"CPU Sets cleared for {process.Name}", + $"PID: {process.ProcessId}"); await this.UpdateSelectedProcessSummaryAsync(process); } catch (Exception ex) { - this.SetContextError(MapProcessOperationException(ex)); + var message = MapProcessOperationException(ex); + this.SetContextError(message); + await this.LogUserActionAsync( + "CpuSetsClearFailed", + message, + $"Process: {process.Name}, PID: {process.ProcessId}"); await this.TryRefreshContextProcessSummaryAsync(process); } } @@ -1567,6 +1596,10 @@ private async Task SetContextCpuPriorityAsync(ProcessModel? process, ProcessPrio if (ProcessPriorityGuardrails.IsBlocked(priority)) { this.SetContextError(ProcessOperationUserMessages.RealtimePriorityBlocked); + await this.LogUserActionAsync( + "ProcessPriorityBlocked", + ProcessOperationUserMessages.RealtimePriorityBlocked, + $"Process: {process.Name}, PID: {process.ProcessId}, Priority: {priority}"); await this.UpdateSelectedProcessSummaryAsync(process); return; } @@ -1588,6 +1621,10 @@ private async Task SetContextCpuPriorityAsync(ProcessModel? process, ProcessPrio _ = this.notificationService.ShowNotificationAsync("Priority applied", $"{process.Name}: {priority}", NotificationType.Success); } + await this.LogUserActionAsync( + "ProcessPriorityChanged", + $"CPU priority changed for {process.Name}: {priority}", + $"PID: {process.ProcessId}"); await this.UpdateSelectedProcessSummaryAsync(process); } catch (Exception ex) @@ -1595,6 +1632,10 @@ private async Task SetContextCpuPriorityAsync(ProcessModel? process, ProcessPrio var message = MapProcessOperationException(ex); this.SetContextError(message); _ = this.notificationService.ShowNotificationAsync("Priority blocked", message, NotificationType.Warning); + await this.LogUserActionAsync( + "ProcessPriorityChangeFailed", + message, + $"Process: {process.Name}, PID: {process.ProcessId}, Priority: {priority}"); await this.TryRefreshContextProcessSummaryAsync(process); } } @@ -1618,19 +1659,33 @@ private async Task SetContextMemoryPriorityAsync(ProcessModel? process, ProcessM var result = await this.memoryPriorityService.SetMemoryPriorityAsync(process, priority); if (!result.Success) { - this.SetContextError(string.IsNullOrWhiteSpace(result.UserMessage) + var message = string.IsNullOrWhiteSpace(result.UserMessage) ? ProcessOperationUserMessages.AccessDenied - : result.UserMessage); + : result.UserMessage; + this.SetContextError(message); + await this.LogUserActionAsync( + "ProcessMemoryPriorityFailed", + message, + $"Process: {process.Name}, PID: {process.ProcessId}, Priority: {priority}"); await this.UpdateSelectedProcessSummaryAsync(process); return; } this.SetStatus($"Memory priority applied successfully to {process.Name}: {priority}.", false); + await this.LogUserActionAsync( + "ProcessMemoryPriorityChanged", + $"Memory priority changed for {process.Name}: {priority}", + $"PID: {process.ProcessId}"); await this.UpdateSelectedProcessSummaryAsync(process); } catch (Exception ex) { - this.SetContextError(MapProcessOperationException(ex)); + var message = MapProcessOperationException(ex); + this.SetContextError(message); + await this.LogUserActionAsync( + "ProcessMemoryPriorityFailed", + message, + $"Process: {process.Name}, PID: {process.ProcessId}, Priority: {priority}"); await this.UpdateSelectedProcessSummaryAsync(process); } } diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs index ea0f81d..b2c1c23 100644 --- a/ViewModels/SettingsViewModel.cs +++ b/ViewModels/SettingsViewModel.cs @@ -257,6 +257,7 @@ await this.notificationService.ShowSuccessNotificationAsync( "Application settings have been saved successfully"); } + await this.LogUserActionAsync("SettingsChanged", "Settings saved and applied"); this.Logger.LogInformation("Settings saved successfully"); } catch (Exception ex) @@ -271,6 +272,7 @@ await this.notificationService.ShowErrorNotificationAsync( "Settings Error", "Failed to save settings", ex); + await this.LogUserActionAsync("SettingsChangeFailed", $"Failed to save settings: {ex.Message}"); } finally { @@ -291,12 +293,14 @@ private async Task ResetToDefaultsAsync() this.UpdatePendingChangesState(); this.StatusMessage = "Settings reset to defaults (not saved yet)"; + await this.LogUserActionAsync("SettingsChanged", "Settings reset to defaults pending save"); this.Logger.LogInformation("Settings reset to defaults"); } catch (Exception ex) { this.StatusMessage = $"Error resetting settings: {ex.Message}"; this.Logger.LogError(ex, "Error resetting settings"); + await this.LogUserActionAsync("SettingsChangeFailed", $"Failed to reset settings: {ex.Message}"); } finally { @@ -348,6 +352,7 @@ await this.notificationService.ShowSuccessNotificationAsync( $"Settings and rules exported to {Path.GetFileName(saveDialog.FileName)}"); this.Logger.LogInformation("Configuration bundle exported to {Path}", saveDialog.FileName); + await this.LogUserActionAsync("SettingsChanged", "Configuration exported", Path.GetFileName(saveDialog.FileName)); } catch (Exception ex) { @@ -358,6 +363,7 @@ await this.notificationService.ShowErrorNotificationAsync( "Export Error", "Failed to export settings", ex); + await this.LogUserActionAsync("SettingsChangeFailed", $"Failed to export settings: {ex.Message}"); } finally { @@ -411,6 +417,7 @@ await this.notificationService.ShowSuccessNotificationAsync( $"Settings and rules imported from {Path.GetFileName(importPath)}"); this.Logger.LogInformation("Configuration bundle imported from {Path}", importPath); + await this.LogUserActionAsync("SettingsChanged", "Configuration bundle imported", Path.GetFileName(importPath)); return; } @@ -426,6 +433,7 @@ await this.notificationService.ShowNotificationAsync( NotificationType.Information); this.Logger.LogInformation("Legacy settings imported from {Path}", importPath); + await this.LogUserActionAsync("SettingsChanged", "Legacy settings imported", Path.GetFileName(importPath)); } catch (Exception ex) { @@ -436,6 +444,7 @@ await this.notificationService.ShowErrorNotificationAsync( "Import Error", "Failed to import configuration", ex); + await this.LogUserActionAsync("SettingsChangeFailed", $"Failed to import settings: {ex.Message}"); } finally { @@ -754,4 +763,3 @@ private sealed class ConfigurationBundle } } } - diff --git a/ViewModels/SystemTweaksViewModel.cs b/ViewModels/SystemTweaksViewModel.cs index bfda05f..a7e46d5 100644 --- a/ViewModels/SystemTweaksViewModel.cs +++ b/ViewModels/SystemTweaksViewModel.cs @@ -45,8 +45,9 @@ public partial class SystemTweaksViewModel : BaseViewModel public SystemTweaksViewModel( ISystemTweaksService systemTweaksService, INotificationService notificationService, - ILogger logger) - : base(logger, null) + ILogger logger, + IEnhancedLoggingService? enhancedLoggingService = null) + : base(logger, enhancedLoggingService) { this.systemTweaksService = systemTweaksService; this.notificationService = notificationService; @@ -262,6 +263,10 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => await this.notificationService.ShowSuccessNotificationAsync( "System Tweak Updated", $"{item.Name} has been {(newState ? "enabled" : "disabled")}"); + await this.LogUserActionAsync( + "SystemTweakApplied", + $"{item.Name} {(newState ? "enabled" : "disabled")}", + item.TweakType.ToString()); } else { @@ -273,6 +278,10 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => await this.notificationService.ShowErrorNotificationAsync( "System Tweak Failed", $"Failed to {(newState ? "enable" : "disable")} {item.Name}"); + await this.LogUserActionAsync( + "SystemTweakFailed", + $"Failed to {(newState ? "enable" : "disable")} {item.Name}", + item.TweakType.ToString()); } } catch (Exception ex) @@ -282,6 +291,10 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => this.SetError($"Error toggling {item.Name}", ex); }); this.Logger.LogError(ex, "Error toggling tweak {TweakName}", item.Name); + await this.LogUserActionAsync( + "SystemTweakFailed", + $"Error toggling {item.Name}: {ex.Message}", + item.TweakType.ToString()); } } diff --git a/Views/PowerPlanView.xaml b/Views/PowerPlanView.xaml index 0debf52..d095797 100644 --- a/Views/PowerPlanView.xaml +++ b/Views/PowerPlanView.xaml @@ -38,26 +38,27 @@ - - - - - - - + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Date: Fri, 22 May 2026 19:13:06 +0200 Subject: [PATCH 03/12] Fix final v1.2.0 validation polish --- Services/SystemTrayMenuPlacement.cs | 32 +++- Services/SystemTrayService.cs | 15 +- Services/ThemeService.cs | 27 +++ .../PerformanceViewModelDiagnosticsTests.cs | 71 +++++++- .../ProcessMonitorManagerServiceTests.cs | 12 +- .../ProcessViewXamlBindingTests.cs | 11 ++ .../RetryPolicyServiceTests.cs | 10 +- .../SettingsViewModelThemeTests.cs | 106 ++++++++++++ .../SystemTrayPlacementHelperTests.cs | 41 ++++- .../SystemTweaksViewModelTests.cs | 155 ++++++++++++++++++ .../ThemeDictionaryPolicyTests.cs | 24 +++ ViewModels/LogViewerViewModel.cs | 144 ++++++++-------- ViewModels/PerformanceViewModel.cs | 17 +- ViewModels/SettingsViewModel.cs | 53 ++++-- ViewModels/SystemTweaksViewModel.cs | 21 ++- Views/ProcessView.xaml | 5 + 16 files changed, 636 insertions(+), 108 deletions(-) create mode 100644 Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs create mode 100644 Tests/ThreadPilot.Core.Tests/SystemTweaksViewModelTests.cs diff --git a/Services/SystemTrayMenuPlacement.cs b/Services/SystemTrayMenuPlacement.cs index ee3a7be..246b72e 100644 --- a/Services/SystemTrayMenuPlacement.cs +++ b/Services/SystemTrayMenuPlacement.cs @@ -5,13 +5,43 @@ namespace ThreadPilot.Services public static class SystemTrayMenuPlacement { public static Point ResolveMenuOpenPoint(Point cursorPosition, Point lastKnownPosition) + { + return ResolveMenuOpenPoint( + cursorPosition, + lastKnownPosition, + Rectangle.Empty, + Rectangle.Empty); + } + + public static Point ResolveMenuOpenPoint( + Point cursorPosition, + Point lastKnownPosition, + Rectangle trayBounds, + Rectangle fallbackWorkingArea) { if (!cursorPosition.IsEmpty) { return cursorPosition; } - return lastKnownPosition.IsEmpty ? new Point(1, 1) : lastKnownPosition; + if (!lastKnownPosition.IsEmpty) + { + return lastKnownPosition; + } + + if (!trayBounds.IsEmpty) + { + return new Point(trayBounds.Left + (trayBounds.Width / 2), trayBounds.Top + (trayBounds.Height / 2)); + } + + if (!fallbackWorkingArea.IsEmpty) + { + return new Point( + Math.Max(fallbackWorkingArea.Left + 1, fallbackWorkingArea.Right - 8), + Math.Max(fallbackWorkingArea.Top + 1, fallbackWorkingArea.Bottom - 8)); + } + + return new Point(16, 16); } } } diff --git a/Services/SystemTrayService.cs b/Services/SystemTrayService.cs index f150985..049f44d 100644 --- a/Services/SystemTrayService.cs +++ b/Services/SystemTrayService.cs @@ -263,10 +263,21 @@ private void OnTrayIconMouseUp(object? sender, MouseEventArgs e) return; } - var openPoint = SystemTrayMenuPlacement.ResolveMenuOpenPoint(Cursor.Position, this.lastContextMenuOpenPoint); + var cursorPosition = Cursor.Position; + var workingArea = Screen.FromPoint(cursorPosition.IsEmpty ? this.lastContextMenuOpenPoint : cursorPosition).WorkingArea; + var openPoint = SystemTrayMenuPlacement.ResolveMenuOpenPoint( + cursorPosition, + this.lastContextMenuOpenPoint, + Rectangle.Empty, + workingArea); this.lastContextMenuOpenPoint = openPoint; - this.contextMenu.Show(openPoint); + if (this.contextMenu.Visible) + { + this.contextMenu.Close(ToolStripDropDownCloseReason.CloseCalled); + } + + this.contextMenu.Show(openPoint, ToolStripDropDownDirection.Default); } private void OnQuickApplyClick(object? sender, EventArgs e) diff --git a/Services/ThemeService.cs b/Services/ThemeService.cs index 17defeb..7ceafba 100644 --- a/Services/ThemeService.cs +++ b/Services/ThemeService.cs @@ -30,6 +30,8 @@ public class ThemeService : IThemeService, IDisposable private readonly ILogger logger; private ResourceDictionary? activeThemeDictionary; + private Uri? activeThemeUri; + private bool hasAppliedTheme; public bool IsDarkTheme { get; private set; } @@ -42,6 +44,12 @@ public ThemeService(ILogger logger) public void ApplyTheme(bool useDarkTheme) { var targetUri = new Uri(useDarkTheme ? DarkThemeDictionaryPath : LightThemeDictionaryPath, UriKind.Relative); + if (this.hasAppliedTheme && + this.IsDarkTheme == useDarkTheme && + string.Equals(this.activeThemeUri?.OriginalString, targetUri.OriginalString, StringComparison.OrdinalIgnoreCase)) + { + return; + } var appResources = System.Windows.Application.Current?.Resources; if (appResources == null) @@ -60,6 +68,8 @@ public void ApplyTheme(bool useDarkTheme) this.activeThemeDictionary = ThemeDictionaryPolicy.ReplaceThreadPilotThemeDictionary(appResources, targetUri); this.IsDarkTheme = useDarkTheme; + this.activeThemeUri = targetUri; + this.hasAppliedTheme = true; } catch (Exception ex) { @@ -190,15 +200,32 @@ internal static ResourceDictionary ReplaceThreadPilotThemeDictionary( ArgumentNullException.ThrowIfNull(targetUri); ArgumentNullException.ThrowIfNull(dictionaryFactory); + ResourceDictionary? matchingDictionary = null; for (int i = appResources.MergedDictionaries.Count - 1; i >= 0; i--) { var dictionary = appResources.MergedDictionaries[i]; if (IsThreadPilotThemeDictionary(dictionary.Source?.OriginalString)) { + if (matchingDictionary == null && + string.Equals(dictionary.Source?.OriginalString, targetUri.OriginalString, StringComparison.OrdinalIgnoreCase)) + { + matchingDictionary = dictionary; + continue; + } + appResources.MergedDictionaries.RemoveAt(i); } } + if (matchingDictionary != null) + { + appResources.MergedDictionaries.Remove(matchingDictionary); + appResources.MergedDictionaries.Insert( + GetInsertionIndex(appResources.MergedDictionaries.Count), + matchingDictionary); + return matchingDictionary; + } + var nextDictionary = dictionaryFactory(targetUri); appResources.MergedDictionaries.Insert( GetInsertionIndex(appResources.MergedDictionaries.Count), diff --git a/Tests/ThreadPilot.Core.Tests/PerformanceViewModelDiagnosticsTests.cs b/Tests/ThreadPilot.Core.Tests/PerformanceViewModelDiagnosticsTests.cs index f3e9e87..ae1a3dc 100644 --- a/Tests/ThreadPilot.Core.Tests/PerformanceViewModelDiagnosticsTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PerformanceViewModelDiagnosticsTests.cs @@ -89,6 +89,59 @@ public async Task StartMonitoringCommand_LogsFailure_WhenServiceFails() Times.Once); } + [Fact] + public async Task StopMonitoringCommand_StopsServiceAndLogsSuccess() + { + var harness = new Harness(); + var viewModel = harness.CreateViewModel(); + + await viewModel.StopMonitoringCommand.ExecuteAsync(null); + + harness.Performance.Verify(service => service.StopMonitoringAsync(), Times.Once); + harness.Logging.Verify( + logger => logger.LogUserActionAsync( + "OptimizationMonitoringStopped", + "Performance monitoring stopped", + null), + Times.Once); + Assert.Equal("Performance monitoring stopped", viewModel.StatusMessage); + } + + [Fact] + public async Task RefreshMetricsCommand_WhenMetricsFails_LogsFailureSafely() + { + var harness = new Harness(metricsThrows: true); + var viewModel = harness.CreateViewModel(); + + await viewModel.RefreshMetricsCommand.ExecuteAsync(null); + + Assert.True(viewModel.HasError); + harness.Logging.Verify( + logger => logger.LogUserActionAsync( + "OptimizationActionFailed", + It.Is(details => details.Contains("Failed to refresh performance snapshot")), + null), + Times.Once); + } + + [Fact] + public async Task ClearHistoricalDataCommand_ClearsServiceAndLogsSuccess() + { + var harness = new Harness(); + var viewModel = harness.CreateViewModel(); + + await viewModel.ClearHistoricalDataCommand.ExecuteAsync(null); + + harness.Performance.Verify(service => service.ClearHistoricalDataAsync(), Times.Once); + harness.Logging.Verify( + logger => logger.LogUserActionAsync( + "OptimizationHistoryCleared", + "Historical metrics cleared", + null), + Times.Once); + Assert.Equal("Historical data cleared", viewModel.StatusMessage); + } + [Fact] public void ShowAdvancedDiagnostics_DefaultsToHidden() { @@ -111,11 +164,21 @@ private sealed class Harness public Mock Logging { get; } = new(MockBehavior.Loose); - public Harness(bool startMonitoringThrows = false) + public Harness(bool startMonitoringThrows = false, bool metricsThrows = false) { - this.Performance - .Setup(x => x.GetSystemMetricsAsync(It.IsAny())) - .ReturnsAsync(new SystemPerformanceMetrics()); + if (metricsThrows) + { + this.Performance + .Setup(x => x.GetSystemMetricsAsync(It.IsAny())) + .ThrowsAsync(new InvalidOperationException("metrics unavailable")); + } + else + { + this.Performance + .Setup(x => x.GetSystemMetricsAsync(It.IsAny())) + .ReturnsAsync(new SystemPerformanceMetrics()); + } + this.Performance .Setup(x => x.GetHistoricalDataAsync(It.IsAny())) .ReturnsAsync(new List()); diff --git a/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs index 6176337..7db3f9d 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessMonitorManagerServiceTests.cs @@ -800,9 +800,17 @@ private sealed class FakeProcessMonitorService : IProcessMonitorService { public event EventHandler? ProcessStarted; - public event EventHandler? ProcessStopped; + public event EventHandler? ProcessStopped + { + add { } + remove { } + } - public event EventHandler? MonitoringStatusChanged; + public event EventHandler? MonitoringStatusChanged + { + add { } + remove { } + } public List RunningProcesses { get; } = new(); diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs index 6281eeb..25bf456 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs @@ -75,6 +75,17 @@ public void ProcessGridRowStyle_HighlightsSelectedRowsWithAccentTheme() Assert.Contains("BorderThickness", serialized, StringComparison.Ordinal); } + [Fact] + public void ProcessGridContextMenu_MenuItemsUseNormalFontWeight() + { + var document = XDocument.Load(ProcessViewPath, LoadOptions.PreserveWhitespace); + var serialized = document.ToString(SaveOptions.DisableFormatting); + + Assert.Contains(" + () => { attempts++; if (attempts < 3) { - throw new InvalidOperationException("transient"); + return Task.FromException(new InvalidOperationException("transient")); } - return "ok"; + return Task.FromResult("ok"); }, policy); @@ -67,10 +67,10 @@ public async Task ExecuteAsync_DoesNotRetry_WhenPredicateRejectsException() await Assert.ThrowsAsync(async () => { await service.ExecuteAsync( - async () => + () => { attempts++; - throw new UnauthorizedAccessException("denied"); + return Task.FromException(new UnauthorizedAccessException("denied")); }, policy); }); diff --git a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs new file mode 100644 index 0000000..ef240c5 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs @@ -0,0 +1,106 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Collections.ObjectModel; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + using ThreadPilot.Services.Abstractions; + using ThreadPilot.ViewModels; + + public sealed class SettingsViewModelThemeTests + { + [Fact] + public void ChangingTheme_AppliesThemeAndLogsVisibleActivityEntry() + { + var harness = new Harness(); + var viewModel = harness.CreateViewModel(); + + viewModel.Settings.UseDarkTheme = true; + + harness.Theme.Verify(service => service.ApplyTheme(true), Times.Once); + harness.Tray.Verify(service => service.ApplyTheme(true), Times.Once); + harness.Logging.Verify( + service => service.LogUserActionAsync( + "ThemeChanged", + "Theme changed to Dark", + null), + Times.Once); + Assert.Equal("Theme changed to Dark.", viewModel.StatusMessage); + } + + [Fact] + public void ChangingTheme_ToSameValue_DoesNotApplyOrLogAgain() + { + var harness = new Harness(initialDarkTheme: true); + var viewModel = harness.CreateViewModel(); + + viewModel.Settings.UseDarkTheme = true; + + harness.Theme.Verify(service => service.ApplyTheme(It.IsAny()), Times.Never); + harness.Tray.Verify(service => service.ApplyTheme(It.IsAny()), Times.Never); + harness.Logging.Verify( + service => service.LogUserActionAsync(It.IsAny(), It.IsAny(), It.IsAny()), + Times.Never); + } + + private sealed class Harness + { + private readonly ApplicationSettingsModel settings; + + public Mock SettingsService { get; } = new(MockBehavior.Loose); + + public Mock Notifications { get; } = new(MockBehavior.Loose); + + public Mock Autostart { get; } = new(MockBehavior.Loose); + + public Mock PowerPlans { get; } = new(MockBehavior.Loose); + + public Mock Associations { get; } = new(MockBehavior.Loose); + + public Mock ProcessMonitorManager { get; } = new(MockBehavior.Loose); + + public Mock Theme { get; } = new(MockBehavior.Loose); + + public Mock Tray { get; } = new(MockBehavior.Loose); + + public Mock Logging { get; } = new(MockBehavior.Loose); + + public Harness(bool initialDarkTheme = false) + { + this.settings = new ApplicationSettingsModel + { + UseDarkTheme = initialDarkTheme, + HasUserThemePreference = initialDarkTheme, + }; + this.SettingsService.SetupGet(service => service.Settings).Returns(this.settings); + this.PowerPlans + .Setup(service => service.GetPowerPlansAsync()) + .ReturnsAsync(new ObservableCollection()); + this.PowerPlans + .Setup(service => service.GetCustomPowerPlansAsync()) + .ReturnsAsync(new ObservableCollection()); + this.PowerPlans + .Setup(service => service.GetActivePowerPlan()) + .ReturnsAsync((PowerPlanModel?)null); + this.Associations + .Setup(service => service.GetDefaultPowerPlanAsync()) + .ReturnsAsync((string.Empty, string.Empty)); + } + + public SettingsViewModel CreateViewModel() => + new( + NullLogger.Instance, + this.SettingsService.Object, + this.Notifications.Object, + this.Autostart.Object, + this.PowerPlans.Object, + this.Associations.Object, + this.ProcessMonitorManager.Object, + this.Theme.Object, + this.Tray.Object, + new GitHubUpdateChecker(new Mock().Object), + this.Logging.Object); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/SystemTrayPlacementHelperTests.cs b/Tests/ThreadPilot.Core.Tests/SystemTrayPlacementHelperTests.cs index a19b19f..bd94dc9 100644 --- a/Tests/ThreadPilot.Core.Tests/SystemTrayPlacementHelperTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SystemTrayPlacementHelperTests.cs @@ -10,9 +10,14 @@ public void ResolveMenuOpenPoint_UsesCursorPositionOnFirstOpen() { var cursor = new Point(1200, 700); - var result = SystemTrayMenuPlacement.ResolveMenuOpenPoint(cursor, Point.Empty); + var result = SystemTrayMenuPlacement.ResolveMenuOpenPoint( + cursor, + Point.Empty, + Rectangle.Empty, + new Rectangle(0, 0, 1920, 1080)); Assert.Equal(cursor, result); + Assert.NotEqual(Point.Empty, result); } [Fact] @@ -20,9 +25,41 @@ public void ResolveMenuOpenPoint_FallsBackToLastKnownPoint_WhenCursorIsUnavailab { var lastKnown = new Point(1600, 900); - var result = SystemTrayMenuPlacement.ResolveMenuOpenPoint(Point.Empty, lastKnown); + var result = SystemTrayMenuPlacement.ResolveMenuOpenPoint( + Point.Empty, + lastKnown, + Rectangle.Empty, + new Rectangle(0, 0, 1920, 1080)); Assert.Equal(lastKnown, result); } + + [Fact] + public void ResolveMenuOpenPoint_WhenFirstCursorIsUnavailable_UsesTaskbarAreaInsteadOfTopLeft() + { + var result = SystemTrayMenuPlacement.ResolveMenuOpenPoint( + Point.Empty, + Point.Empty, + Rectangle.Empty, + new Rectangle(0, 0, 1920, 1080)); + + Assert.NotEqual(Point.Empty, result); + Assert.True(result.X > 0); + Assert.True(result.Y > 0); + } + + [Fact] + public void ResolveMenuOpenPoint_WhenTrayBoundsAreInvalid_FallsBackToCursor() + { + var cursor = new Point(900, 500); + + var result = SystemTrayMenuPlacement.ResolveMenuOpenPoint( + cursor, + Point.Empty, + Rectangle.Empty, + new Rectangle(0, 0, 1920, 1080)); + + Assert.Equal(cursor, result); + } } } diff --git a/Tests/ThreadPilot.Core.Tests/SystemTweaksViewModelTests.cs b/Tests/ThreadPilot.Core.Tests/SystemTweaksViewModelTests.cs new file mode 100644 index 0000000..06f1cb6 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/SystemTweaksViewModelTests.cs @@ -0,0 +1,155 @@ +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + + public sealed class SystemTweaksViewModelTests + { + [Theory] + [InlineData(SystemTweak.CoreParking, "Core Parking")] + [InlineData(SystemTweak.CStates, "C-States")] + [InlineData(SystemTweak.SysMain, "SysMain Service")] + [InlineData(SystemTweak.Prefetch, "Prefetch")] + [InlineData(SystemTweak.PowerThrottling, "Power Throttling")] + [InlineData(SystemTweak.Hpet, "HPET")] + [InlineData(SystemTweak.HighSchedulingCategory, "High Scheduling Category")] + [InlineData(SystemTweak.MenuShowDelay, "Menu Show Delay")] + public async Task ToggleTweakCommand_CallsExpectedServiceAndLogsSuccess(SystemTweak tweakType, string name) + { + var harness = new Harness(); + harness.SetupTweak(tweakType, setResult: true); + var viewModel = harness.CreateViewModel(); + var item = viewModel.TweakItems.Single(tweak => tweak.TweakType == tweakType); + + Assert.NotNull(item.ToggleCommand); + await item.ToggleCommand.ExecuteAsync(item); + + harness.VerifySetCalled(tweakType); + harness.Logging.Verify( + service => service.LogUserActionAsync( + "SystemTweakApplied", + $"{name} enabled", + tweakType.ToString()), + Times.Once); + Assert.Equal($"{name} enabled successfully", viewModel.StatusMessage); + } + + [Fact] + public async Task ToggleTweakCommand_WhenServiceFails_LogsFailureAndShowsSafeStatus() + { + var harness = new Harness(); + harness.Tweaks + .Setup(service => service.SetCoreParkingAsync(true)) + .ReturnsAsync(false); + var viewModel = harness.CreateViewModel(); + var item = viewModel.TweakItems.Single(tweak => tweak.TweakType == SystemTweak.CoreParking); + + Assert.NotNull(item.ToggleCommand); + await item.ToggleCommand.ExecuteAsync(item); + + harness.Logging.Verify( + service => service.LogUserActionAsync( + "SystemTweakFailed", + "Failed to enable Core Parking", + "CoreParking"), + Times.Once); + Assert.True(viewModel.HasError); + Assert.Equal("Failed to toggle Core Parking", viewModel.ErrorMessage); + } + + private sealed class Harness + { + public Mock Tweaks { get; } = new(MockBehavior.Loose); + + public Mock Notifications { get; } = new(MockBehavior.Loose); + + public Mock Logging { get; } = new(MockBehavior.Loose); + + public void SetupTweak(SystemTweak tweakType, bool setResult) + { + switch (tweakType) + { + case SystemTweak.CoreParking: + this.Tweaks.Setup(service => service.SetCoreParkingAsync(true)).ReturnsAsync(setResult); + this.Tweaks.Setup(service => service.GetCoreParkingStatusAsync()).ReturnsAsync(CreateEnabledStatus()); + break; + case SystemTweak.CStates: + this.Tweaks.Setup(service => service.SetCStatesAsync(true)).ReturnsAsync(setResult); + this.Tweaks.Setup(service => service.GetCStatesStatusAsync()).ReturnsAsync(CreateEnabledStatus()); + break; + case SystemTweak.SysMain: + this.Tweaks.Setup(service => service.SetSysMainAsync(true)).ReturnsAsync(setResult); + this.Tweaks.Setup(service => service.GetSysMainStatusAsync()).ReturnsAsync(CreateEnabledStatus()); + break; + case SystemTweak.Prefetch: + this.Tweaks.Setup(service => service.SetPrefetchAsync(true)).ReturnsAsync(setResult); + this.Tweaks.Setup(service => service.GetPrefetchStatusAsync()).ReturnsAsync(CreateEnabledStatus()); + break; + case SystemTweak.PowerThrottling: + this.Tweaks.Setup(service => service.SetPowerThrottlingAsync(true)).ReturnsAsync(setResult); + this.Tweaks.Setup(service => service.GetPowerThrottlingStatusAsync()).ReturnsAsync(CreateEnabledStatus()); + break; + case SystemTweak.Hpet: + this.Tweaks.Setup(service => service.SetHpetAsync(true)).ReturnsAsync(setResult); + this.Tweaks.Setup(service => service.GetHpetStatusAsync()).ReturnsAsync(CreateEnabledStatus()); + break; + case SystemTweak.HighSchedulingCategory: + this.Tweaks.Setup(service => service.SetHighSchedulingCategoryAsync(true)).ReturnsAsync(setResult); + this.Tweaks.Setup(service => service.GetHighSchedulingCategoryStatusAsync()).ReturnsAsync(CreateEnabledStatus()); + break; + case SystemTweak.MenuShowDelay: + this.Tweaks.Setup(service => service.SetMenuShowDelayAsync(true)).ReturnsAsync(setResult); + this.Tweaks.Setup(service => service.GetMenuShowDelayStatusAsync()).ReturnsAsync(CreateEnabledStatus()); + break; + default: + throw new ArgumentOutOfRangeException(nameof(tweakType), tweakType, null); + } + } + + public void VerifySetCalled(SystemTweak tweakType) + { + switch (tweakType) + { + case SystemTweak.CoreParking: + this.Tweaks.Verify(service => service.SetCoreParkingAsync(true), Times.Once); + break; + case SystemTweak.CStates: + this.Tweaks.Verify(service => service.SetCStatesAsync(true), Times.Once); + break; + case SystemTweak.SysMain: + this.Tweaks.Verify(service => service.SetSysMainAsync(true), Times.Once); + break; + case SystemTweak.Prefetch: + this.Tweaks.Verify(service => service.SetPrefetchAsync(true), Times.Once); + break; + case SystemTweak.PowerThrottling: + this.Tweaks.Verify(service => service.SetPowerThrottlingAsync(true), Times.Once); + break; + case SystemTweak.Hpet: + this.Tweaks.Verify(service => service.SetHpetAsync(true), Times.Once); + break; + case SystemTweak.HighSchedulingCategory: + this.Tweaks.Verify(service => service.SetHighSchedulingCategoryAsync(true), Times.Once); + break; + case SystemTweak.MenuShowDelay: + this.Tweaks.Verify(service => service.SetMenuShowDelayAsync(true), Times.Once); + break; + default: + throw new ArgumentOutOfRangeException(nameof(tweakType), tweakType, null); + } + } + + public SystemTweaksViewModel CreateViewModel() => + new( + this.Tweaks.Object, + this.Notifications.Object, + NullLogger.Instance, + this.Logging.Object); + + private static TweakStatus CreateEnabledStatus() => + new() { IsEnabled = true, IsAvailable = true }; + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ThemeDictionaryPolicyTests.cs b/Tests/ThreadPilot.Core.Tests/ThemeDictionaryPolicyTests.cs index 20f9c2a..a006aed 100644 --- a/Tests/ThreadPilot.Core.Tests/ThemeDictionaryPolicyTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ThemeDictionaryPolicyTests.cs @@ -44,6 +44,30 @@ public void ReplaceThreadPilotThemeDictionary_RemovesOldThemeDictionariesAndAppe dictionary => ThemeDictionaryPolicy.IsThreadPilotThemeDictionary(dictionary.Source?.OriginalString)); } + [Fact] + public void ReplaceThreadPilotThemeDictionary_WhenRequestedThemeIsAlreadyActive_ReusesExistingDictionary() + { + var darkThemeUri = new Uri("Themes/FluentDark.xaml", UriKind.Relative); + var resources = new ResourceDictionary(); + var activeDictionary = CreateDictionaryWithSource(darkThemeUri); + resources.MergedDictionaries.Add(new ResourceDictionary()); + resources.MergedDictionaries.Add(activeDictionary); + var factoryCalls = 0; + + var result = ThemeDictionaryPolicy.ReplaceThreadPilotThemeDictionary( + resources, + darkThemeUri, + uri => + { + factoryCalls++; + return CreateDictionaryWithSource(uri); + }); + + Assert.Same(activeDictionary, result); + Assert.Equal(0, factoryCalls); + Assert.Same(activeDictionary, resources.MergedDictionaries[^1]); + } + [Theory] [InlineData("Themes/FluentDark.xaml")] [InlineData("Themes/FluentLight.xaml")] diff --git a/ViewModels/LogViewerViewModel.cs b/ViewModels/LogViewerViewModel.cs index 214efcc..0d43191 100644 --- a/ViewModels/LogViewerViewModel.cs +++ b/ViewModels/LogViewerViewModel.cs @@ -33,54 +33,54 @@ namespace ThreadPilot.ViewModels /// public partial class LogViewerViewModel : ObservableObject { - private readonly IEnhancedLoggingService _loggingService; - private readonly IApplicationSettingsService _settingsService; - private readonly ILogger _logger; - - [ObservableProperty] - private ObservableCollection _logEntries = new(); - - [ObservableProperty] - private LogEntryDisplayModel? _selectedLogEntry; - - [ObservableProperty] - private string _searchText = string.Empty; - - [ObservableProperty] - private LogLevel _selectedLogLevel = LogLevel.Information; - - [ObservableProperty] - private string _selectedCategory = "All"; - - [ObservableProperty] - private DateTime _fromDate = DateTime.Today.AddDays(-7); - - [ObservableProperty] - private DateTime _toDate = DateTime.Today.AddDays(1); - - [ObservableProperty] - private bool _isLoading; - - [ObservableProperty] - private string _statusMessage = "Ready"; - - [ObservableProperty] - private LogFileStatistics? _logStatistics; - - [ObservableProperty] - private bool _enableDebugLogging; - - [ObservableProperty] - private int _maxLogFileSizeMb = 10; - - [ObservableProperty] - private int _logRetentionDays = 7; - - [ObservableProperty] - private bool _autoRefresh = true; - - [ObservableProperty] - private int _refreshIntervalSeconds = 30; + private readonly IEnhancedLoggingService loggingService; + private readonly IApplicationSettingsService settingsService; + private readonly ILogger logger; + + [ObservableProperty] + private ObservableCollection logEntries = new(); + + [ObservableProperty] + private LogEntryDisplayModel? selectedLogEntry; + + [ObservableProperty] + private string searchText = string.Empty; + + [ObservableProperty] + private LogLevel selectedLogLevel = LogLevel.Information; + + [ObservableProperty] + private string selectedCategory = "All"; + + [ObservableProperty] + private DateTime fromDate = DateTime.Today.AddDays(-7); + + [ObservableProperty] + private DateTime toDate = DateTime.Today.AddDays(1); + + [ObservableProperty] + private bool isLoading; + + [ObservableProperty] + private string statusMessage = "Ready"; + + [ObservableProperty] + private LogFileStatistics? logStatistics; + + [ObservableProperty] + private bool enableDebugLogging; + + [ObservableProperty] + private int maxLogFileSizeMb = 10; + + [ObservableProperty] + private int logRetentionDays = 7; + + [ObservableProperty] + private bool autoRefresh = true; + + [ObservableProperty] + private int refreshIntervalSeconds = 30; public ObservableCollection AvailableCategories { get; } = new() { @@ -111,9 +111,9 @@ public LogViewerViewModel( IApplicationSettingsService settingsService, ILogger logger) { - this._loggingService = loggingService ?? throw new ArgumentNullException(nameof(loggingService)); - this._settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); - this._logger = logger ?? throw new ArgumentNullException(nameof(logger)); + this.loggingService = loggingService ?? throw new ArgumentNullException(nameof(loggingService)); + this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); // Initialize commands this.RefreshLogsCommand = new AsyncRelayCommand(this.RefreshLogsAsync); @@ -128,7 +128,7 @@ public LogViewerViewModel( this.LoadSettings(); // Start auto-refresh if enabled - if (this._autoRefresh) + if (this.autoRefresh) { this.StartAutoRefresh(); } @@ -148,7 +148,7 @@ public async Task InitializeAsync() } catch (Exception ex) { - this._logger.LogError(ex, "Failed to initialize log viewer"); + this.logger.LogError(ex, "Failed to initialize log viewer"); this.StatusMessage = $"Error: {ex.Message}"; } finally @@ -164,7 +164,7 @@ private async Task RefreshLogsAsync() this.IsLoading = true; this.StatusMessage = "Refreshing logs..."; - var logEntries = await this._loggingService.GetLogEntriesAsync(this.FromDate, this.ToDate); + var logEntries = await this.loggingService.GetLogEntriesAsync(this.FromDate, this.ToDate); // Filter by category and log level var filteredEntries = logEntries.Where(entry => @@ -199,7 +199,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => } catch (Exception ex) { - this._logger.LogError(ex, "Failed to refresh logs"); + this.logger.LogError(ex, "Failed to refresh logs"); this.StatusMessage = $"Error refreshing logs: {ex.Message}"; } finally @@ -212,11 +212,11 @@ private async Task RefreshStatisticsAsync() { try { - this.LogStatistics = await this._loggingService.GetLogStatisticsAsync(); + this.LogStatistics = await this.loggingService.GetLogStatisticsAsync(); } catch (Exception ex) { - this._logger.LogError(ex, "Failed to refresh log statistics"); + this.logger.LogError(ex, "Failed to refresh log statistics"); } } @@ -226,11 +226,11 @@ private async Task ClearLogsAsync() { this.LogEntries.Clear(); this.StatusMessage = "Log display cleared"; - await this._loggingService.LogUserActionAsync("LogsCleared", "User cleared log display"); + await this.loggingService.LogUserActionAsync("LogsCleared", "User cleared log display"); } catch (Exception ex) { - this._logger.LogError(ex, "Failed to clear logs"); + this.logger.LogError(ex, "Failed to clear logs"); this.StatusMessage = $"Error clearing logs: {ex.Message}"; } } @@ -242,17 +242,17 @@ private async Task ExportLogsAsync() this.IsLoading = true; this.StatusMessage = "Exporting logs..."; - var exportPath = await this._loggingService.ExportLogsAsync(this.FromDate, this.ToDate); + var exportPath = await this.loggingService.ExportLogsAsync(this.FromDate, this.ToDate); this.StatusMessage = $"Logs exported to: {exportPath}"; - await this._loggingService.LogUserActionAsync( + await this.loggingService.LogUserActionAsync( "LogsExported", $"Logs exported to {exportPath}", $"DateRange: {this.FromDate:yyyy-MM-dd} to {this.ToDate:yyyy-MM-dd}"); } catch (Exception ex) { - this._logger.LogError(ex, "Failed to export logs"); + this.logger.LogError(ex, "Failed to export logs"); this.StatusMessage = $"Error exporting logs: {ex.Message}"; } finally @@ -268,15 +268,15 @@ private async Task CleanupOldLogsAsync() this.IsLoading = true; this.StatusMessage = "Cleaning up old logs..."; - await this._loggingService.CleanupOldLogsAsync(); + await this.loggingService.CleanupOldLogsAsync(); await this.RefreshStatisticsAsync(); this.StatusMessage = "Old logs cleaned up successfully"; - await this._loggingService.LogUserActionAsync("LogsCleanup", "User initiated log cleanup"); + await this.loggingService.LogUserActionAsync("LogsCleanup", "User initiated log cleanup"); } catch (Exception ex) { - this._logger.LogError(ex, "Failed to cleanup old logs"); + this.logger.LogError(ex, "Failed to cleanup old logs"); this.StatusMessage = $"Error cleaning up logs: {ex.Message}"; } finally @@ -289,16 +289,16 @@ private async Task SaveSettingsAsync() { try { - await this._loggingService.UpdateConfigurationAsync(this.EnableDebugLogging, this.MaxLogFileSizeMb, this.LogRetentionDays); + await this.loggingService.UpdateConfigurationAsync(this.EnableDebugLogging, this.MaxLogFileSizeMb, this.LogRetentionDays); this.StatusMessage = "Logging settings saved successfully"; - await this._loggingService.LogUserActionAsync( + await this.loggingService.LogUserActionAsync( "LoggingSettingsChanged", $"Debug: {this.EnableDebugLogging}, MaxSize: {this.MaxLogFileSizeMb}MB, Retention: {this.LogRetentionDays} days"); } catch (Exception ex) { - this._logger.LogError(ex, "Failed to save logging settings"); + this.logger.LogError(ex, "Failed to save logging settings"); this.StatusMessage = $"Error saving settings: {ex.Message}"; } } @@ -307,7 +307,7 @@ private void OpenLogDirectory() { try { - var logDirectory = this._loggingService.LogDirectoryPath; + var logDirectory = this.loggingService.LogDirectoryPath; if (Directory.Exists(logDirectory)) { System.Diagnostics.Process.Start("explorer.exe", logDirectory); @@ -319,7 +319,7 @@ private void OpenLogDirectory() } catch (Exception ex) { - this._logger.LogError(ex, "Failed to open log directory"); + this.logger.LogError(ex, "Failed to open log directory"); this.StatusMessage = $"Error opening log directory: {ex.Message}"; } } @@ -344,14 +344,14 @@ private void CopyLogEntry(LogEntryDisplayModel? logEntry) } catch (Exception ex) { - this._logger.LogError(ex, "Failed to copy log entry to clipboard"); + this.logger.LogError(ex, "Failed to copy log entry to clipboard"); this.StatusMessage = "Failed to copy log entry"; } } private void LoadSettings() { - var settings = this._settingsService.Settings; + var settings = this.settingsService.Settings; this.EnableDebugLogging = settings.EnableDebugLogging; this.MaxLogFileSizeMb = settings.MaxLogFileSizeMb; this.LogRetentionDays = settings.LogRetentionDays; diff --git a/ViewModels/PerformanceViewModel.cs b/ViewModels/PerformanceViewModel.cs index 4119fef..c23cef9 100644 --- a/ViewModels/PerformanceViewModel.cs +++ b/ViewModels/PerformanceViewModel.cs @@ -209,13 +209,20 @@ public async Task ActivateDiagnosticsAsync() { this.SetStatus("Loading optional diagnostics..."); - await this.RefreshMetricsSnapshotAsync(); + var snapshotLoaded = await this.RefreshMetricsSnapshotAsync(); await this.LoadHistoricalDataAsync(); this.diagnosticsActivated = true; this.MonitoringStateText = this.IsMonitoring ? "Active" : "Stopped"; - this.MonitoringStatusText = this.IsMonitoring ? "Live metrics active" : "Diagnostics snapshot loaded"; - this.SetStatus("Optional diagnostics loaded", false); + if (snapshotLoaded) + { + this.MonitoringStatusText = this.IsMonitoring ? "Live metrics active" : "Diagnostics snapshot loaded"; + this.SetStatus("Optional diagnostics loaded", false); + } + else + { + this.MonitoringStatusText = "Diagnostics snapshot failed"; + } } catch (Exception ex) { @@ -325,7 +332,7 @@ private async Task RefreshMetricsAsync() await this.RefreshMetricsSnapshotAsync(); } - private async Task RefreshMetricsSnapshotAsync() + private async Task RefreshMetricsSnapshotAsync() { try { @@ -339,11 +346,13 @@ private async Task RefreshMetricsSnapshotAsync() this.LastManualRefreshText = $"Refreshed at {DateTime.Now:HH:mm:ss}"; this.SetStatus("Performance snapshot refreshed", false); await this.LogUserActionAsync("OptimizationSnapshotRefreshed", "Performance snapshot refreshed"); + return true; } catch (Exception ex) { this.SetError("Failed to refresh performance snapshot", ex); await this.LogUserActionAsync("OptimizationActionFailed", $"Failed to refresh performance snapshot: {ex.Message}"); + return false; } } diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs index b2c1c23..7aa93e1 100644 --- a/ViewModels/SettingsViewModel.cs +++ b/ViewModels/SettingsViewModel.cs @@ -51,6 +51,7 @@ public partial class SettingsViewModel : BaseViewModel private readonly GitHubUpdateChecker gitHubUpdateChecker; private ApplicationSettingsModel savedSettingsSnapshot; private bool isSyncingFromService = false; + private bool? appliedThemePreference; private string cachedDefaultPowerPlanGuid = string.Empty; private string cachedDefaultPowerPlanName = string.Empty; private static readonly JsonSerializerOptions ImportExportJsonOptions = new() @@ -132,6 +133,7 @@ public SettingsViewModel( // Initialize with current settings this.settings = (ApplicationSettingsModel)this.settingsService.Settings.Clone(); this.savedSettingsSnapshot = (ApplicationSettingsModel)this.settings.Clone(); + this.appliedThemePreference = this.settings.UseDarkTheme; // Initialize commands this.SaveSettingsCommand = new AsyncRelayCommand(this.SaveSettingsAsync); @@ -148,11 +150,15 @@ public SettingsViewModel( // Keep viewmodel in sync with persisted settings this.settingsService.SettingsChanged += this.OnSettingsServiceSettingsChanged; - // Ensure we load the latest persisted settings on startup - _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => await this.RefreshSettingsAsync()); + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher != null) + { + // Ensure we load the latest persisted settings on startup. + _ = dispatcher.InvokeAsync(async () => await this.RefreshSettingsAsync()); - // Initialize data - marshal to UI thread to prevent cross-thread access exceptions - _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => await this.RefreshPowerPlansAsync()); + // Initialize data - marshal to UI thread to prevent cross-thread access exceptions. + _ = dispatcher.InvokeAsync(async () => await this.RefreshPowerPlansAsync()); + } this.Logger.LogInformation("Settings ViewModel initialized"); } @@ -167,13 +173,40 @@ private void OnSettingsPropertyChanged(object? sender, System.ComponentModel.Pro if (string.Equals(e.PropertyName, nameof(ApplicationSettingsModel.UseDarkTheme), StringComparison.Ordinal)) { this.Settings.HasUserThemePreference = true; + this.UpdatePendingChangesState(); + this.ApplyThemePreference(this.Settings.UseDarkTheme, logUserAction: true); + return; + } + + this.UpdatePendingChangesState(); + } - var useDarkTheme = this.Settings.UseDarkTheme; + private void ApplyThemePreference(bool useDarkTheme, bool logUserAction) + { + if (this.appliedThemePreference == useDarkTheme) + { + return; + } + + var themeName = useDarkTheme ? "Dark" : "Light"; + try + { this.themeService.ApplyTheme(useDarkTheme); this.systemTrayService.ApplyTheme(useDarkTheme); - } + this.appliedThemePreference = useDarkTheme; + this.StatusMessage = $"Theme changed to {themeName}."; - this.UpdatePendingChangesState(); + if (logUserAction) + { + _ = this.LogUserActionAsync("ThemeChanged", $"Theme changed to {themeName}"); + } + } + catch (Exception ex) + { + this.StatusMessage = $"Failed to change theme to {themeName}."; + this.Logger.LogError(ex, "Failed to apply theme preference {ThemeName}", themeName); + _ = this.LogUserActionAsync("ThemeChangeFailed", $"Failed to change theme to {themeName}: {ex.Message}"); + } } partial void OnHasUnsavedChangesChanged(bool value) @@ -234,8 +267,7 @@ private async Task SaveSettingsAsync() this.isSyncingFromService = true; this.Settings.UseDarkTheme = useDarkTheme; this.isSyncingFromService = false; - this.themeService.ApplyTheme(useDarkTheme); - this.systemTrayService.ApplyTheme(useDarkTheme); + this.ApplyThemePreference(useDarkTheme, logUserAction: false); // Update monitoring services with new settings this.processMonitorManagerService.UpdateSettings(); @@ -505,8 +537,7 @@ public async Task RefreshSettingsAsync() this.isSyncingFromService = true; this.Settings.UseDarkTheme = useDarkTheme; this.isSyncingFromService = false; - this.themeService.ApplyTheme(useDarkTheme); - this.systemTrayService.ApplyTheme(useDarkTheme); + this.ApplyThemePreference(useDarkTheme, logUserAction: false); this.SetSavedSettingsSnapshot(this.Settings); this.StatusMessage = "Settings loaded"; diff --git a/ViewModels/SystemTweaksViewModel.cs b/ViewModels/SystemTweaksViewModel.cs index a7e46d5..a86233d 100644 --- a/ViewModels/SystemTweaksViewModel.cs +++ b/ViewModels/SystemTweaksViewModel.cs @@ -233,7 +233,7 @@ private async Task ToggleTweakAsync(SystemTweakItem? item) try { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + await InvokeOnUiAsync(() => { this.SetStatus($"Toggling {item.Name}..."); }); @@ -255,7 +255,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => if (success) { await this.UpdateTweakItemStatusAsync(item); - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + await InvokeOnUiAsync(() => { this.SetStatus($"{item.Name} {(newState ? "enabled" : "disabled")} successfully"); }); @@ -270,7 +270,7 @@ await this.LogUserActionAsync( } else { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + await InvokeOnUiAsync(() => { this.SetError($"Failed to toggle {item.Name}", null); }); @@ -286,7 +286,7 @@ await this.LogUserActionAsync( } catch (Exception ex) { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + await InvokeOnUiAsync(() => { this.SetError($"Error toggling {item.Name}", ex); }); @@ -298,6 +298,18 @@ await this.LogUserActionAsync( } } + private static Task InvokeOnUiAsync(Action action) + { + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher == null) + { + action(); + return Task.CompletedTask; + } + + return dispatcher.InvokeAsync(action).Task; + } + private void OnTweakStatusChanged(object? sender, TweakStatusChangedEventArgs e) { try @@ -349,4 +361,3 @@ public partial class SystemTweakItem : ObservableObject public IAsyncRelayCommand? ToggleCommand { get; set; } } } - diff --git a/Views/ProcessView.xaml b/Views/ProcessView.xaml index 9c56d09..1a9c8ec 100644 --- a/Views/ProcessView.xaml +++ b/Views/ProcessView.xaml @@ -152,6 +152,11 @@ + + + From a4e281a00bd74e89e0c53e7d89eff50ac1545cc9 Mon Sep 17 00:00:00 2001 From: PrimeBuild-pc Date: Fri, 22 May 2026 20:43:52 +0200 Subject: [PATCH 04/12] Make Activity Logs a ThreadPilot audit trail --- MainWindow.xaml | 2 +- Services/ActivityAuditService.cs | 244 ++++++ Services/IActivityAuditService.cs | 42 + Services/PersistentRuleAutoApplyService.cs | 45 +- Services/ServiceConfiguration.cs | 2 + .../ActivityAuditServiceTests.cs | 65 ++ .../LogViewerActivityAuditTests.cs | 61 ++ .../PerformanceViewModelDiagnosticsTests.cs | 25 +- .../PersistentRuleAutoApplyServiceTests.cs | 24 +- .../PowerPlanViewModelTests.cs | 21 +- .../ProcessViewModelContextMenuTests.cs | 83 +- .../ServiceConfigurationTests.cs | 10 + .../SettingsViewModelThemeTests.cs | 11 +- .../SystemTweaksViewModelTests.cs | 13 +- ViewModels/BaseViewModel.cs | 12 +- ViewModels/LogViewerViewModel.cs | 808 ++++++++++-------- ViewModels/MainWindowViewModel.cs | 5 +- ViewModels/PerformanceViewModel.cs | 28 +- ViewModels/PowerPlanViewModel.cs | 9 +- .../ProcessViewModel.Behaviors.partial.cs | 92 +- ViewModels/ProcessViewModel.cs | 3 +- ViewModels/SettingsViewModel.cs | 5 +- ViewModels/SystemTweaksViewModel.cs | 5 +- Views/LogViewerView.xaml | 44 +- 24 files changed, 1219 insertions(+), 440 deletions(-) create mode 100644 Services/ActivityAuditService.cs create mode 100644 Services/IActivityAuditService.cs create mode 100644 Tests/ThreadPilot.Core.Tests/ActivityAuditServiceTests.cs create mode 100644 Tests/ThreadPilot.Core.Tests/LogViewerActivityAuditTests.cs diff --git a/MainWindow.xaml b/MainWindow.xaml index 6972afe..f79f321 100644 --- a/MainWindow.xaml +++ b/MainWindow.xaml @@ -143,7 +143,7 @@ - + diff --git a/Services/ActivityAuditService.cs b/Services/ActivityAuditService.cs new file mode 100644 index 0000000..2f3631f --- /dev/null +++ b/Services/ActivityAuditService.cs @@ -0,0 +1,244 @@ +namespace ThreadPilot.Services +{ + using Microsoft.Extensions.Logging; + + public sealed class ActivityAuditService : IActivityAuditService + { + private const int MaxEntries = 1000; + private readonly ILogger logger; + private readonly object syncRoot = new(); + private readonly List entries = new(); + + public event EventHandler? EntryAdded; + + public ActivityAuditService(ILogger logger) + { + this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task LogInfoAsync(string category, string message, string? details = null) => + this.AddEntryAsync(category, ActivityAuditSeverity.Info, message, details); + + public Task LogSuccessAsync(string category, string message, string? details = null) => + this.AddEntryAsync(category, ActivityAuditSeverity.Success, message, details); + + public Task LogWarningAsync(string category, string message, string? details = null) => + this.AddEntryAsync(category, ActivityAuditSeverity.Warning, message, details); + + public Task LogErrorAsync(string category, string message, string? details = null) => + this.AddEntryAsync(category, ActivityAuditSeverity.Error, message, details); + + public Task LogUserActionAsync(string action, string details, string? context = null) + { + var entry = ActivityAuditActionMapper.Map(action, details, context); + return this.AddEntryAsync(entry.Category, entry.Severity, entry.Message, entry.Details); + } + + public Task> GetEntriesAsync(DateTime? fromDate = null, DateTime? toDate = null) + { + lock (this.syncRoot) + { + IEnumerable snapshot = this.entries; + if (fromDate.HasValue) + { + snapshot = snapshot.Where(entry => entry.Timestamp >= fromDate.Value); + } + + if (toDate.HasValue) + { + snapshot = snapshot.Where(entry => entry.Timestamp <= toDate.Value); + } + + return Task.FromResult>( + snapshot + .OrderByDescending(entry => entry.Timestamp) + .ToList()); + } + } + + public Task ClearDisplayAsync() + { + lock (this.syncRoot) + { + this.entries.Clear(); + } + + return Task.CompletedTask; + } + + private Task AddEntryAsync(string category, ActivityAuditSeverity severity, string message, string? details) + { + if (string.IsNullOrWhiteSpace(message)) + { + return Task.CompletedTask; + } + + var entry = new ActivityAuditEntry + { + Timestamp = DateTime.Now, + Category = string.IsNullOrWhiteSpace(category) ? ActivityAuditCategories.Diagnostics : category.Trim(), + Severity = severity, + Message = message.Trim(), + Details = string.IsNullOrWhiteSpace(details) ? null : details.Trim(), + }; + + lock (this.syncRoot) + { + this.entries.Add(entry); + if (this.entries.Count > MaxEntries) + { + this.entries.RemoveRange(0, this.entries.Count - MaxEntries); + } + } + + this.logger.Log( + ToLogLevel(severity), + "Activity audit: {Category} {Severity}: {Message}", + entry.Category, + entry.Severity, + entry.Message); + this.EntryAdded?.Invoke(this, entry); + return Task.CompletedTask; + } + + private static LogLevel ToLogLevel(ActivityAuditSeverity severity) => + severity switch + { + ActivityAuditSeverity.Error => LogLevel.Error, + ActivityAuditSeverity.Warning => LogLevel.Warning, + _ => LogLevel.Information, + }; + } + + internal static class ActivityAuditCategories + { + public const string Process = "Process"; + public const string Affinity = "Affinity"; + public const string Priority = "Priority"; + public const string MemoryPriority = "Memory Priority"; + public const string Rules = "Rules"; + public const string PowerPlans = "Power Plans"; + public const string Settings = "Settings"; + public const string Tweaks = "Tweaks"; + public const string Optimization = "Optimization"; + public const string Diagnostics = "Diagnostics"; + public const string Safety = "Safety"; + } + + internal static class ActivityAuditActionMapper + { + public static ActivityAuditEntry Map(string action, string details, string? context) + { + var category = ResolveCategory(action); + var severity = ResolveSeverity(action, details); + return new ActivityAuditEntry + { + Category = category, + Severity = severity, + Message = string.IsNullOrWhiteSpace(details) ? action : details, + Details = context, + }; + } + + private static string ResolveCategory(string action) + { + if (action.StartsWith("ProcessAffinity", StringComparison.OrdinalIgnoreCase) || + action.StartsWith("CpuSets", StringComparison.OrdinalIgnoreCase)) + { + return ActivityAuditCategories.Affinity; + } + + if (action.StartsWith("ProcessPriority", StringComparison.OrdinalIgnoreCase)) + { + return ActivityAuditCategories.Priority; + } + + if (action.StartsWith("ProcessMemoryPriority", StringComparison.OrdinalIgnoreCase)) + { + return ActivityAuditCategories.MemoryPriority; + } + + if (action.StartsWith("PersistentRule", StringComparison.OrdinalIgnoreCase) || + action.Contains("Association", StringComparison.OrdinalIgnoreCase)) + { + return ActivityAuditCategories.Rules; + } + + if (action.StartsWith("PowerPlan", StringComparison.OrdinalIgnoreCase) || + action.StartsWith("PowerPlans", StringComparison.OrdinalIgnoreCase)) + { + return ActivityAuditCategories.PowerPlans; + } + + if (action.StartsWith("Theme", StringComparison.OrdinalIgnoreCase) || + action.StartsWith("Settings", StringComparison.OrdinalIgnoreCase) || + action.Contains("Configuration", StringComparison.OrdinalIgnoreCase)) + { + return ActivityAuditCategories.Settings; + } + + if (action.StartsWith("SystemTweak", StringComparison.OrdinalIgnoreCase) || + action.Contains("IdleServer", StringComparison.OrdinalIgnoreCase) || + action.Contains("RegistryPriority", StringComparison.OrdinalIgnoreCase)) + { + return ActivityAuditCategories.Tweaks; + } + + if (action.StartsWith("Optimization", StringComparison.OrdinalIgnoreCase)) + { + return ActivityAuditCategories.Optimization; + } + + if (action.Contains("Protected", StringComparison.OrdinalIgnoreCase) || + action.Contains("Elevation", StringComparison.OrdinalIgnoreCase)) + { + return ActivityAuditCategories.Safety; + } + + if (action.StartsWith("Process", StringComparison.OrdinalIgnoreCase)) + { + return ActivityAuditCategories.Process; + } + + return ActivityAuditCategories.Diagnostics; + } + + private static ActivityAuditSeverity ResolveSeverity(string action, string details) + { + if (ContainsAny(action, "Blocked", "Denied") || ContainsAny(details, "blocked", "denied", "anti-cheat", "protected")) + { + return ActivityAuditSeverity.Warning; + } + + if (ContainsAny(action, "Failed", "Failure", "Error") || ContainsAny(details, "failed", "error", "exited")) + { + return ActivityAuditSeverity.Error; + } + + if (ContainsAny( + action, + "Applied", + "Changed", + "Saved", + "Updated", + "Deleted", + "Imported", + "Added", + "Cleared", + "Refreshed", + "Started", + "Stopped", + "Exported", + "Opened", + "Copied")) + { + return ActivityAuditSeverity.Success; + } + + return ActivityAuditSeverity.Info; + } + + private static bool ContainsAny(string value, params string[] terms) => + terms.Any(term => value.Contains(term, StringComparison.OrdinalIgnoreCase)); + } +} diff --git a/Services/IActivityAuditService.cs b/Services/IActivityAuditService.cs new file mode 100644 index 0000000..9ccab1a --- /dev/null +++ b/Services/IActivityAuditService.cs @@ -0,0 +1,42 @@ +namespace ThreadPilot.Services +{ + public enum ActivityAuditSeverity + { + Info, + Success, + Warning, + Error, + } + + public sealed record ActivityAuditEntry + { + public DateTime Timestamp { get; init; } + + public string Category { get; init; } = string.Empty; + + public ActivityAuditSeverity Severity { get; init; } + + public string Message { get; init; } = string.Empty; + + public string? Details { get; init; } + } + + public interface IActivityAuditService + { + event EventHandler? EntryAdded; + + Task LogInfoAsync(string category, string message, string? details = null); + + Task LogSuccessAsync(string category, string message, string? details = null); + + Task LogWarningAsync(string category, string message, string? details = null); + + Task LogErrorAsync(string category, string message, string? details = null); + + Task LogUserActionAsync(string action, string details, string? context = null); + + Task> GetEntriesAsync(DateTime? fromDate = null, DateTime? toDate = null); + + Task ClearDisplayAsync(); + } +} diff --git a/Services/PersistentRuleAutoApplyService.cs b/Services/PersistentRuleAutoApplyService.cs index 47d642c..11d9161 100644 --- a/Services/PersistentRuleAutoApplyService.cs +++ b/Services/PersistentRuleAutoApplyService.cs @@ -69,6 +69,7 @@ public sealed class PersistentRuleAutoApplyService : IPersistentRuleAutoApplySer private readonly IPersistentRulesEngine rulesEngine; private readonly IApplicationSettingsService settingsService; private readonly ILogger logger; + private readonly IActivityAuditService? activityAuditService; private readonly Func nowProvider; private readonly TimeSpan cooldown; private readonly ConcurrentDictionary recentAttempts = new(); @@ -78,8 +79,9 @@ public PersistentRuleAutoApplyService( IPersistentProcessRuleMatcher matcher, IPersistentRulesEngine rulesEngine, IApplicationSettingsService settingsService, - ILogger logger) - : this(ruleStore, matcher, rulesEngine, settingsService, logger, () => DateTimeOffset.UtcNow, DefaultCooldown) + ILogger logger, + IActivityAuditService? activityAuditService = null) + : this(ruleStore, matcher, rulesEngine, settingsService, logger, () => DateTimeOffset.UtcNow, DefaultCooldown, activityAuditService) { } @@ -90,7 +92,8 @@ public PersistentRuleAutoApplyService( IApplicationSettingsService settingsService, ILogger logger, Func nowProvider, - TimeSpan cooldown) + TimeSpan cooldown, + IActivityAuditService? activityAuditService = null) { this.ruleStore = ruleStore ?? throw new ArgumentNullException(nameof(ruleStore)); this.matcher = matcher ?? throw new ArgumentNullException(nameof(matcher)); @@ -99,6 +102,7 @@ public PersistentRuleAutoApplyService( this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.nowProvider = nowProvider ?? throw new ArgumentNullException(nameof(nowProvider)); this.cooldown = cooldown <= TimeSpan.Zero ? DefaultCooldown : cooldown; + this.activityAuditService = activityAuditService; } public async Task> ApplyForDiscoveredProcessesAsync( @@ -204,7 +208,7 @@ private async Task> ApplyForProcess var results = applyResults.Select(PersistentRuleAutoApplyResult.FromApplyResult).ToList(); foreach (var result in results) { - this.LogResult(result); + await this.LogResultAsync(result).ConfigureAwait(false); } return results; @@ -252,7 +256,7 @@ private void ClearAttemptsForMissingProcesses(HashSet currentProcessIds) } } - private void LogResult(PersistentRuleAutoApplyResult result) + private async Task LogResultAsync(PersistentRuleAutoApplyResult result) { if (result.Success) { @@ -261,6 +265,7 @@ private void LogResult(PersistentRuleAutoApplyResult result) result.RuleId, result.ProcessName, result.ProcessId); + await this.LogActivityResultAsync(result).ConfigureAwait(false); return; } @@ -274,6 +279,36 @@ private void LogResult(PersistentRuleAutoApplyResult result) result.ProcessName, result.ProcessId, result.UserMessage); + await this.LogActivityResultAsync(result).ConfigureAwait(false); + } + + private async Task LogActivityResultAsync(PersistentRuleAutoApplyResult result) + { + if (this.activityAuditService == null) + { + return; + } + + var action = result.Success + ? "PersistentRuleAutoApplied" + : "PersistentRuleAutoApplyFailed"; + var message = result.Success + ? $"Auto-applied saved rule for {result.ProcessName}." + : $"Failed to auto-apply saved rule for {result.ProcessName}: {result.UserMessage}"; + + try + { + await this.activityAuditService + .LogUserActionAsync( + action, + message, + $"Rule: {result.RuleId}, PID: {result.ProcessId}") + .ConfigureAwait(false); + } + catch (Exception ex) + { + this.logger.LogWarning(ex, "Failed to write persistent rule activity audit entry"); + } } private bool IsEnabled() => diff --git a/Services/ServiceConfiguration.cs b/Services/ServiceConfiguration.cs index cb661f1..1bf3426 100644 --- a/Services/ServiceConfiguration.cs +++ b/Services/ServiceConfiguration.cs @@ -66,6 +66,7 @@ private static IServiceCollection ConfigureServiceInfrastructure(this IServiceCo // Enhanced logging service services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(sp => @@ -236,6 +237,7 @@ public static void ValidateServiceConfiguration(IServiceProvider serviceProvider typeof(IPowerPlanService), typeof(ICpuTopologyService), typeof(IEnhancedLoggingService), + typeof(IActivityAuditService), typeof(IApplicationSettingsService), }; diff --git a/Tests/ThreadPilot.Core.Tests/ActivityAuditServiceTests.cs b/Tests/ThreadPilot.Core.Tests/ActivityAuditServiceTests.cs new file mode 100644 index 0000000..7aa3c54 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/ActivityAuditServiceTests.cs @@ -0,0 +1,65 @@ +namespace ThreadPilot.Core.Tests +{ + using Microsoft.Extensions.Logging.Abstractions; + using ThreadPilot.Services; + + public sealed class ActivityAuditServiceTests + { + [Theory] + [InlineData("ThemeChanged", "Theme changed to Dark", "Settings", ActivityAuditSeverity.Success)] + [InlineData("SystemTweakApplied", "Core Parking enabled", "Tweaks", ActivityAuditSeverity.Success)] + [InlineData("SystemTweakFailed", "Failed to enable Core Parking", "Tweaks", ActivityAuditSeverity.Error)] + [InlineData("OptimizationMonitoringStarted", "Performance monitoring started", "Optimization", ActivityAuditSeverity.Success)] + [InlineData("OptimizationActionFailed", "Failed to start performance monitoring: unavailable", "Optimization", ActivityAuditSeverity.Error)] + [InlineData("PowerPlanApplied", "Applied power plan Gaming", "Power Plans", ActivityAuditSeverity.Success)] + [InlineData("PowerPlanDeleted", "Deleted power plan Gaming", "Power Plans", ActivityAuditSeverity.Success)] + [InlineData("PowerPlansRefreshed", "Refreshed power plan list", "Power Plans", ActivityAuditSeverity.Success)] + [InlineData("ProcessPriorityChanged", "CPU priority changed for Game.exe: High", "Priority", ActivityAuditSeverity.Success)] + [InlineData("ProcessPriorityChangeFailed", "Windows denied this change.", "Priority", ActivityAuditSeverity.Warning)] + [InlineData("ProcessPriorityBlocked", "Realtime priority is blocked by ThreadPilot.", "Priority", ActivityAuditSeverity.Warning)] + [InlineData("ProcessMemoryPriorityChanged", "Memory priority changed for Game.exe: Low", "Memory Priority", ActivityAuditSeverity.Success)] + [InlineData("ProcessMemoryPriorityFailed", "The process appears protected by anti-cheat or process protection.", "Memory Priority", ActivityAuditSeverity.Warning)] + [InlineData("CpuSetsCleared", "CPU Sets cleared for Game.exe", "Affinity", ActivityAuditSeverity.Success)] + [InlineData("CpuSetsClearFailed", "The process exited before ThreadPilot could apply the change.", "Affinity", ActivityAuditSeverity.Error)] + [InlineData("ProcessAffinityApplied", "Affinity applied successfully to Game.exe", "Affinity", ActivityAuditSeverity.Success)] + [InlineData("ProcessAffinityFailed", "The process appears protected by anti-cheat or process protection.", "Affinity", ActivityAuditSeverity.Warning)] + [InlineData("PersistentRuleSaved", "Saved rule for Game.exe.", "Rules", ActivityAuditSeverity.Success)] + [InlineData("PersistentRuleSaveFailed", "Failed to save rule for Game.exe.", "Rules", ActivityAuditSeverity.Error)] + [InlineData("PersistentRuleAutoApplied", "Auto-applied saved rule for Game.exe.", "Rules", ActivityAuditSeverity.Success)] + [InlineData("PersistentRuleAutoApplyFailed", "Failed to auto-apply saved rule for Game.exe: protected process.", "Rules", ActivityAuditSeverity.Warning)] + public async Task LogUserActionAsync_CreatesVisibleActivityEntry( + string action, + string details, + string expectedCategory, + ActivityAuditSeverity expectedSeverity) + { + var service = new ActivityAuditService(NullLogger.Instance); + + await service.LogUserActionAsync(action, details, "PID: 42"); + + var entry = Assert.Single(await service.GetEntriesAsync()); + Assert.Equal(expectedCategory, entry.Category); + Assert.Equal(expectedSeverity, entry.Severity); + Assert.Equal(details, entry.Message); + Assert.Equal("PID: 42", entry.Details); + } + + [Fact] + public async Task GetEntriesAsync_ReturnsMostRecentFirstAndPreservesTimestamp() + { + var service = new ActivityAuditService(NullLogger.Instance); + + await service.LogInfoAsync("Diagnostics", "First"); + await Task.Delay(5); + await service.LogSuccessAsync("Power Plans", "Second"); + + var entries = await service.GetEntriesAsync(); + + Assert.Collection( + entries, + entry => Assert.Equal("Second", entry.Message), + entry => Assert.Equal("First", entry.Message)); + Assert.All(entries, entry => Assert.NotEqual(default, entry.Timestamp)); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/LogViewerActivityAuditTests.cs b/Tests/ThreadPilot.Core.Tests/LogViewerActivityAuditTests.cs new file mode 100644 index 0000000..dee81b8 --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/LogViewerActivityAuditTests.cs @@ -0,0 +1,61 @@ +namespace ThreadPilot.Core.Tests +{ + using CommunityToolkit.Mvvm.Input; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + using ThreadPilot.ViewModels; + + public sealed class LogViewerActivityAuditTests + { + [Fact] + public async Task InitializeAsync_LoadsVisibleThreadPilotActivityEntries() + { + var audit = new ActivityAuditService(NullLogger.Instance); + await audit.LogSuccessAsync("Power Plans", "Applied power plan Gaming", "Guid: game"); + var viewModel = CreateViewModel(audit); + + await viewModel.InitializeAsync(); + + var entry = Assert.Single(viewModel.LogEntries); + Assert.Equal("Power Plans", entry.Category); + Assert.Equal("Applied power plan Gaming", entry.Message); + Assert.Equal(LogLevel.Information, entry.Level); + Assert.Equal("Guid: game", entry.Details); + } + + [Fact] + public async Task ClearLogsCommand_ClearsOnlyVisibleActivityDisplayWithoutAddingNoise() + { + var audit = new ActivityAuditService(NullLogger.Instance); + await audit.LogSuccessAsync("Power Plans", "Applied power plan Gaming"); + var viewModel = CreateViewModel(audit); + await viewModel.InitializeAsync(); + + await ((IAsyncRelayCommand)viewModel.ClearLogsCommand).ExecuteAsync(null); + + Assert.Empty(viewModel.LogEntries); + Assert.Single(await audit.GetEntriesAsync()); + } + + private static LogViewerViewModel CreateViewModel(IActivityAuditService audit) + { + var logging = new Mock(MockBehavior.Loose); + logging + .Setup(service => service.GetLogStatisticsAsync()) + .ReturnsAsync(new LogFileStatistics()); + var settings = new Mock(MockBehavior.Loose); + settings + .SetupGet(service => service.Settings) + .Returns(new ApplicationSettingsModel()); + + return new LogViewerViewModel( + audit, + logging.Object, + settings.Object, + NullLogger.Instance); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/PerformanceViewModelDiagnosticsTests.cs b/Tests/ThreadPilot.Core.Tests/PerformanceViewModelDiagnosticsTests.cs index ae1a3dc..4d43f20 100644 --- a/Tests/ThreadPilot.Core.Tests/PerformanceViewModelDiagnosticsTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PerformanceViewModelDiagnosticsTests.cs @@ -38,6 +38,7 @@ public async Task ActivateDiagnosticsAsync_LoadsSnapshotWithoutStartingLiveMonit harness.PowerPlan.Verify(x => x.GetActivePowerPlan(), Times.Once); harness.Performance.Verify(x => x.StartMonitoringAsync(), Times.Never); Assert.False(viewModel.IsMonitoring); + Assert.Empty(await harness.Audit.GetEntriesAsync()); } [Fact] @@ -70,6 +71,11 @@ public async Task StartMonitoringCommand_LogsSuccess_WhenServiceStarts() "Performance monitoring started", null), Times.Once); + var entry = Assert.Single( + await harness.Audit.GetEntriesAsync(), + entry => entry.Message == "Performance monitoring started"); + Assert.Equal("Optimization", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); } [Fact] @@ -87,6 +93,11 @@ public async Task StartMonitoringCommand_LogsFailure_WhenServiceFails() It.Is(details => details.Contains("Failed to start performance monitoring")), null), Times.Once); + var entry = Assert.Single( + await harness.Audit.GetEntriesAsync(), + entry => entry.Message.Contains("Failed to start performance monitoring")); + Assert.Equal("Optimization", entry.Category); + Assert.Equal(ActivityAuditSeverity.Error, entry.Severity); } [Fact] @@ -104,6 +115,9 @@ public async Task StopMonitoringCommand_StopsServiceAndLogsSuccess() "Performance monitoring stopped", null), Times.Once); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Optimization", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); Assert.Equal("Performance monitoring stopped", viewModel.StatusMessage); } @@ -122,6 +136,9 @@ public async Task RefreshMetricsCommand_WhenMetricsFails_LogsFailureSafely() It.Is(details => details.Contains("Failed to refresh performance snapshot")), null), Times.Once); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Optimization", entry.Category); + Assert.Equal(ActivityAuditSeverity.Error, entry.Severity); } [Fact] @@ -139,6 +156,9 @@ public async Task ClearHistoricalDataCommand_ClearsServiceAndLogsSuccess() "Historical metrics cleared", null), Times.Once); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Optimization", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); Assert.Equal("Historical data cleared", viewModel.StatusMessage); } @@ -164,6 +184,8 @@ private sealed class Harness public Mock Logging { get; } = new(MockBehavior.Loose); + public ActivityAuditService Audit { get; } = new(NullLogger.Instance); + public Harness(bool startMonitoringThrows = false, bool metricsThrows = false) { if (metricsThrows) @@ -226,7 +248,8 @@ public PerformanceViewModel CreateViewModel() => this.ProcessMonitorManager.Object, this.SystemTweaks.Object, NullLogger.Instance, - this.Logging.Object); + this.Logging.Object, + this.Audit); } } } diff --git a/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs b/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs index abf287e..a2e2913 100644 --- a/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PersistentRuleAutoApplyServiceTests.cs @@ -17,11 +17,16 @@ public async Task ApplyForProcessStartAsync_WhenMatchingEnabledRuleExists_CallsR var process = CreateProcess(); var rule = CreateRule(); var engine = CreateEngine(rule, CreateSuccess(rule, process)); - var service = CreateService([rule], engine.Object); + var audit = new ActivityAuditService(NullLogger.Instance); + var service = CreateService([rule], engine.Object, audit: audit); var results = await service.ApplyForProcessStartAsync(process); Assert.Single(results); + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Equal("Rules", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + Assert.Contains("Auto-applied saved rule", entry.Message); engine.Verify( x => x.ApplyMatchingRulesAsync( process, @@ -75,11 +80,13 @@ public async Task ApplyForProcessStartAsync_DoesNotReapplySameRuleDuringCooldown var process = CreateProcess(); var rule = CreateRule(); var engine = CreateEngine(rule, CreateSuccess(rule, process)); - var service = CreateService([rule], engine.Object, nowProvider: () => now); + var audit = new ActivityAuditService(NullLogger.Instance); + var service = CreateService([rule], engine.Object, nowProvider: () => now, audit: audit); await service.ApplyForProcessStartAsync(process); await service.ApplyForProcessStartAsync(process); + Assert.Single(await audit.GetEntriesAsync()); engine.Verify( x => x.ApplyMatchingRulesAsync( process, @@ -136,13 +143,18 @@ public async Task ApplyForProcessStartAsync_WithAccessDeniedFailure_ReturnsFailu var rule = CreateRule(); var failure = CreateFailure(rule, process, ProcessOperationUserMessages.AccessDenied, isAccessDenied: true); var engine = CreateEngine(rule, failure); - var service = CreateService([rule], engine.Object); + var audit = new ActivityAuditService(NullLogger.Instance); + var service = CreateService([rule], engine.Object, audit: audit); var result = Assert.Single(await service.ApplyForProcessStartAsync(process)); Assert.False(result.Success); Assert.True(result.IsAccessDenied); Assert.Equal(ProcessOperationUserMessages.AccessDenied, result.UserMessage); + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Equal("Rules", entry.Category); + Assert.Equal(ActivityAuditSeverity.Warning, entry.Severity); + Assert.Contains("Failed to auto-apply saved rule", entry.Message); } [Fact] @@ -373,7 +385,8 @@ private static PersistentRuleAutoApplyService CreateService( IReadOnlyList rules, IPersistentRulesEngine engine, ApplicationSettingsModel? settings = null, - Func? nowProvider = null) => + Func? nowProvider = null, + IActivityAuditService? audit = null) => new( new FakePersistentProcessRuleStore(rules), new PersistentProcessRuleMatcher(), @@ -381,7 +394,8 @@ private static PersistentRuleAutoApplyService CreateService( CreateSettingsService(settings ?? new ApplicationSettingsModel()), NullLogger.Instance, nowProvider ?? (() => DateTimeOffset.UtcNow), - TimeSpan.FromSeconds(30)); + TimeSpan.FromSeconds(30), + audit); private static Mock CreateEngine( PersistentProcessRule rule, diff --git a/Tests/ThreadPilot.Core.Tests/PowerPlanViewModelTests.cs b/Tests/ThreadPilot.Core.Tests/PowerPlanViewModelTests.cs index 1ec4b15..c637735 100644 --- a/Tests/ThreadPilot.Core.Tests/PowerPlanViewModelTests.cs +++ b/Tests/ThreadPilot.Core.Tests/PowerPlanViewModelTests.cs @@ -26,6 +26,10 @@ public async Task DeletePowerPlanCommand_CallsServiceRefreshesAndLogs_WhenPlanIs "Deleted power plan Gaming", $"Guid: {Harness.DeleteGuid}"), Times.Once); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Power Plans", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + Assert.Equal("Deleted power plan Gaming", entry.Message); Assert.Equal("Power plan deleted: Gaming.", viewModel.StatusMessage); Assert.False(viewModel.HasError); } @@ -42,6 +46,10 @@ public async Task DeletePowerPlanCommand_BlocksActivePlanBeforeCallingService() harness.PowerPlan.Verify(service => service.DeletePowerPlanAsync(It.IsAny()), Times.Never); Assert.Equal("Switch to another power plan before deleting the active plan.", viewModel.StatusMessage); Assert.True(viewModel.HasError); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Power Plans", entry.Category); + Assert.Equal(ActivityAuditSeverity.Warning, entry.Severity); + Assert.Contains("Switch to another power plan", entry.Message); } [Fact] @@ -73,6 +81,10 @@ public async Task SetActivePlanCommand_ShowsSuccessStatusAndLogs() "Applied power plan Gaming", $"Guid: {Harness.DeleteGuid}"), Times.Once); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Power Plans", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + Assert.Equal("Applied power plan Gaming", entry.Message); } [Fact] @@ -90,6 +102,10 @@ public async Task RefreshPowerPlansCommand_ShowsCompletionStatusAndLogs() "Refreshed power plan list", null), Times.Once); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Power Plans", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + Assert.Equal("Refreshed power plan list", entry.Message); } private sealed class Harness @@ -101,6 +117,8 @@ private sealed class Harness public Mock Logging { get; } = new(MockBehavior.Loose); + public ActivityAuditService Audit { get; } = new(NullLogger.Instance); + public Harness(bool deleteSucceeds = true) { var active = new PowerPlanModel { Guid = ActiveGuid, Name = "Balanced", IsActive = true }; @@ -127,7 +145,8 @@ public PowerPlanViewModel CreateViewModel() => new( NullLogger.Instance, this.PowerPlan.Object, - this.Logging.Object); + this.Logging.Object, + this.Audit); } } } diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs index 1f6d3f0..95b6018 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewModelContextMenuTests.cs @@ -15,9 +15,11 @@ public async Task ContextCpuPriorityCommand_CallsSafePriorityServicePath() { var processService = CreateProcessService(); var enhancedLoggingService = new Mock(MockBehavior.Loose); + var audit = new ActivityAuditService(NullLogger.Instance); var viewModel = CreateViewModel( processService.Object, - enhancedLoggingService: enhancedLoggingService.Object); + enhancedLoggingService: enhancedLoggingService.Object, + activityAuditService: audit); var process = CreateProcess(priority: ProcessPriorityClass.Normal); await viewModel.SetContextHighPriorityCommand.ExecuteAsync(process); @@ -33,6 +35,10 @@ public async Task ContextCpuPriorityCommand_CallsSafePriorityServicePath() Times.Once); Assert.Equal(ProcessOperationUserMessages.HighPriorityWarning, viewModel.StatusMessage); Assert.False(viewModel.HasError); + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Equal("Priority", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + Assert.Contains("High", entry.Message); } [Fact] @@ -41,10 +47,12 @@ public async Task ApplyContextAffinityCommand_UsesProvidedRowProcess() var processService = CreateProcessService(); var coordinator = CreateAffinityCoordinator(); var enhancedLoggingService = new Mock(MockBehavior.Loose); + var audit = new ActivityAuditService(NullLogger.Instance); var viewModel = CreateViewModel( processService.Object, processAffinityApplyCoordinator: coordinator.Object, - enhancedLoggingService: enhancedLoggingService.Object); + enhancedLoggingService: enhancedLoggingService.Object, + activityAuditService: audit); viewModel.CpuCores = [ new CpuCoreModel { LogicalCoreId = 0, IsSelected = true }, @@ -68,6 +76,9 @@ public async Task ApplyContextAffinityCommand_UsesProvidedRowProcess() It.Is(context => context.Contains("Process: Game.exe") && context.Contains("PID: 100"))), Times.Once); Assert.Same(rowProcess, viewModel.SelectedProcess); + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Equal("Affinity", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); } [Fact] @@ -143,6 +154,25 @@ public void ContextCpuPriorityActions_DoNotExposeRealtimeAsNormalAction() Assert.Contains(ProcessPriorityClass.High, viewModel.ContextMenuCpuPriorityActions); } + [Fact] + public async Task SetPriorityCommand_WhenRealtimeRequested_LogsVisibleBlockedEntry() + { + var processService = CreateProcessService(); + var audit = new ActivityAuditService(NullLogger.Instance); + var viewModel = CreateViewModel(processService.Object, activityAuditService: audit); + viewModel.SelectedProcess = CreateProcess(); + + await viewModel.SetPriorityCommand.ExecuteAsync(ProcessPriorityClass.RealTime); + + processService.Verify( + service => service.SetProcessPriority(It.IsAny(), ProcessPriorityClass.RealTime), + Times.Never); + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Equal("Priority", entry.Category); + Assert.Equal(ActivityAuditSeverity.Warning, entry.Severity); + Assert.Equal(ProcessOperationUserMessages.RealtimePriorityBlocked, entry.Message); + } + [Fact] public async Task ContextMemoryPriorityCommand_CallsMemoryPriorityService() { @@ -154,17 +184,22 @@ public async Task ContextMemoryPriorityCommand_CallsMemoryPriorityService() .Setup(service => service.GetMemoryPriorityAsync(It.IsAny())) .ReturnsAsync(ProcessMemoryPriority.Low); var enhancedLoggingService = new Mock(MockBehavior.Loose); + var audit = new ActivityAuditService(NullLogger.Instance); var process = CreateProcess(); var viewModel = CreateViewModel( CreateProcessService().Object, memoryPriorityService: memoryPriorityService.Object, - enhancedLoggingService: enhancedLoggingService.Object); + enhancedLoggingService: enhancedLoggingService.Object, + activityAuditService: audit); await viewModel.SetContextMemoryPriorityLowCommand.ExecuteAsync(process); memoryPriorityService.Verify( service => service.SetMemoryPriorityAsync(process, ProcessMemoryPriority.Low), Times.Once); + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Equal("Memory Priority", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); enhancedLoggingService.Verify( service => service.LogUserActionAsync( "ProcessMemoryPriorityChanged", @@ -185,14 +220,19 @@ public async Task ContextMemoryPriorityCommand_WhenServiceFails_ShowsSafeUserMes "Access is denied.", isAccessDenied: true)); var process = CreateProcess(); + var audit = new ActivityAuditService(NullLogger.Instance); var viewModel = CreateViewModel( CreateProcessService().Object, - memoryPriorityService: memoryPriorityService.Object); + memoryPriorityService: memoryPriorityService.Object, + activityAuditService: audit); await viewModel.SetContextMemoryPriorityNormalCommand.ExecuteAsync(process); Assert.Equal(ProcessOperationUserMessages.AccessDenied, viewModel.StatusMessage); Assert.True(viewModel.HasError); + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Equal("Memory Priority", entry.Category); + Assert.Equal(ActivityAuditSeverity.Warning, entry.Severity); } [Fact] @@ -270,11 +310,15 @@ public async Task ClearContextCpuSetsCommand_CallsSafeCpuSetClearPath() .Setup(service => service.ClearProcessCpuSetAsync(It.IsAny())) .ReturnsAsync(true); var process = CreateProcess(); - var viewModel = CreateViewModel(processService.Object); + var audit = new ActivityAuditService(NullLogger.Instance); + var viewModel = CreateViewModel(processService.Object, activityAuditService: audit); await viewModel.ClearContextCpuSetsCommand.ExecuteAsync(process); processService.Verify(service => service.ClearProcessCpuSetAsync(process), Times.Once); + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Equal("Affinity", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); } [Fact] @@ -282,12 +326,28 @@ public async Task RefreshContextProcessInfoCommand_RefreshesSelectedProcessInfo( { var processService = CreateProcessService(); var process = CreateProcess(); - var viewModel = CreateViewModel(processService.Object); + var audit = new ActivityAuditService(NullLogger.Instance); + var viewModel = CreateViewModel(processService.Object, activityAuditService: audit); await viewModel.RefreshContextProcessInfoCommand.ExecuteAsync(process); processService.Verify(service => service.RefreshProcessInfo(process), Times.Once); Assert.Equal("Process info refreshed for Game.exe.", viewModel.StatusMessage); + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Equal("Process", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + } + + [Fact] + public async Task RefreshProcessesCommand_DoesNotCreateActivityAuditEntry() + { + var processService = CreateProcessService(); + var audit = new ActivityAuditService(NullLogger.Instance); + var viewModel = CreateViewModel(processService.Object, activityAuditService: audit); + + await viewModel.RefreshProcessesCommand.ExecuteAsync(null); + + Assert.Empty(await audit.GetEntriesAsync()); } [Fact] @@ -468,11 +528,13 @@ public async Task ApplyAffinityAndSaveAsRuleCommand_WhenAffinityApplyFails_DoesN ProcessOperationUserMessages.AccessDenied, "Access denied.", isAccessDenied: true)); + var audit = new ActivityAuditService(NullLogger.Instance); var viewModel = CreateViewModel( CreateProcessService().Object, processAffinityApplyCoordinator: coordinator.Object, persistentRuleStore: ruleStore, - processRuleCreationService: CreateRuleCreationService(ruleStore)); + processRuleCreationService: CreateRuleCreationService(ruleStore), + activityAuditService: audit); viewModel.CpuCores = [ new CpuCoreModel { LogicalCoreId = 0, IsSelected = true }, @@ -483,6 +545,9 @@ public async Task ApplyAffinityAndSaveAsRuleCommand_WhenAffinityApplyFails_DoesN Assert.Empty(ruleStore.SavedRules); Assert.Equal(ProcessOperationUserMessages.AccessDenied, viewModel.StatusMessage); Assert.True(viewModel.HasError); + var entry = Assert.Single(await audit.GetEntriesAsync()); + Assert.Equal("Affinity", entry.Category); + Assert.Equal(ActivityAuditSeverity.Warning, entry.Severity); } [Fact] @@ -572,7 +637,8 @@ private static ProcessViewModel CreateViewModel( IProcessRuleCreationService? processRuleCreationService = null, Action? clipboardSetter = null, Action? executableLocationOpener = null, - IEnhancedLoggingService? enhancedLoggingService = null) + IEnhancedLoggingService? enhancedLoggingService = null, + IActivityAuditService? activityAuditService = null) { var virtualizedProcessService = new Mock(MockBehavior.Loose); virtualizedProcessService.SetupProperty( @@ -601,6 +667,7 @@ private static ProcessViewModel CreateViewModel( gameModeService.Object, processAffinityApplyCoordinator: processAffinityApplyCoordinator, enhancedLoggingService: enhancedLoggingService, + activityAuditService: activityAuditService, memoryPriorityService: memoryPriorityService, persistentRuleStore: persistentRuleStore, persistentRuleMatcher: new PersistentProcessRuleMatcher(), diff --git a/Tests/ThreadPilot.Core.Tests/ServiceConfigurationTests.cs b/Tests/ThreadPilot.Core.Tests/ServiceConfigurationTests.cs index 80bc93e..1012cb7 100644 --- a/Tests/ThreadPilot.Core.Tests/ServiceConfigurationTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ServiceConfigurationTests.cs @@ -29,6 +29,16 @@ public void ConfigureApplicationServices_RegistersProcessRuleCreationService() Assert.IsType(service); } + [Fact] + public void ConfigureApplicationServices_RegistersActivityAuditService() + { + using var provider = CreateProvider(); + + var service = provider.GetRequiredService(); + + Assert.IsType(service); + } + [Fact] public void ApplicationSettings_DefaultsToPersistentRulesAutoApplyEnabled() { diff --git a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs index ef240c5..8bec8dd 100644 --- a/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SettingsViewModelThemeTests.cs @@ -11,7 +11,7 @@ namespace ThreadPilot.Core.Tests public sealed class SettingsViewModelThemeTests { [Fact] - public void ChangingTheme_AppliesThemeAndLogsVisibleActivityEntry() + public async Task ChangingTheme_AppliesThemeAndLogsVisibleActivityEntry() { var harness = new Harness(); var viewModel = harness.CreateViewModel(); @@ -26,6 +26,10 @@ public void ChangingTheme_AppliesThemeAndLogsVisibleActivityEntry() "Theme changed to Dark", null), Times.Once); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Settings", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + Assert.Equal("Theme changed to Dark", entry.Message); Assert.Equal("Theme changed to Dark.", viewModel.StatusMessage); } @@ -66,6 +70,8 @@ private sealed class Harness public Mock Logging { get; } = new(MockBehavior.Loose); + public ActivityAuditService Audit { get; } = new(NullLogger.Instance); + public Harness(bool initialDarkTheme = false) { this.settings = new ApplicationSettingsModel @@ -100,7 +106,8 @@ public SettingsViewModel CreateViewModel() => this.Theme.Object, this.Tray.Object, new GitHubUpdateChecker(new Mock().Object), - this.Logging.Object); + this.Logging.Object, + this.Audit); } } } diff --git a/Tests/ThreadPilot.Core.Tests/SystemTweaksViewModelTests.cs b/Tests/ThreadPilot.Core.Tests/SystemTweaksViewModelTests.cs index 06f1cb6..7ee02d9 100644 --- a/Tests/ThreadPilot.Core.Tests/SystemTweaksViewModelTests.cs +++ b/Tests/ThreadPilot.Core.Tests/SystemTweaksViewModelTests.cs @@ -33,6 +33,10 @@ public async Task ToggleTweakCommand_CallsExpectedServiceAndLogsSuccess(SystemTw $"{name} enabled", tweakType.ToString()), Times.Once); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Tweaks", entry.Category); + Assert.Equal(ActivityAuditSeverity.Success, entry.Severity); + Assert.Equal($"{name} enabled", entry.Message); Assert.Equal($"{name} enabled successfully", viewModel.StatusMessage); } @@ -55,6 +59,10 @@ public async Task ToggleTweakCommand_WhenServiceFails_LogsFailureAndShowsSafeSta "Failed to enable Core Parking", "CoreParking"), Times.Once); + var entry = Assert.Single(await harness.Audit.GetEntriesAsync()); + Assert.Equal("Tweaks", entry.Category); + Assert.Equal(ActivityAuditSeverity.Error, entry.Severity); + Assert.Equal("Failed to enable Core Parking", entry.Message); Assert.True(viewModel.HasError); Assert.Equal("Failed to toggle Core Parking", viewModel.ErrorMessage); } @@ -67,6 +75,8 @@ private sealed class Harness public Mock Logging { get; } = new(MockBehavior.Loose); + public ActivityAuditService Audit { get; } = new(NullLogger.Instance); + public void SetupTweak(SystemTweak tweakType, bool setResult) { switch (tweakType) @@ -146,7 +156,8 @@ public SystemTweaksViewModel CreateViewModel() => this.Tweaks.Object, this.Notifications.Object, NullLogger.Instance, - this.Logging.Object); + this.Logging.Object, + this.Audit); private static TweakStatus CreateEnabledStatus() => new() { IsEnabled = true, IsAvailable = true }; diff --git a/ViewModels/BaseViewModel.cs b/ViewModels/BaseViewModel.cs index 0df9a8f..8cbe570 100644 --- a/ViewModels/BaseViewModel.cs +++ b/ViewModels/BaseViewModel.cs @@ -30,6 +30,7 @@ public abstract partial class BaseViewModel : ObservableObject, IDisposable { protected readonly ILogger Logger; protected readonly IEnhancedLoggingService? EnhancedLoggingService; + protected readonly IActivityAuditService? ActivityAuditService; private bool disposed; private CancellationTokenSource? statusLifetimeCts; private bool preserveStatusUntilReplaced; @@ -51,10 +52,14 @@ public abstract partial class BaseViewModel : ObservableObject, IDisposable [ObservableProperty] private string errorMessage = string.Empty; - protected BaseViewModel(ILogger logger, IEnhancedLoggingService? enhancedLoggingService = null) + protected BaseViewModel( + ILogger logger, + IEnhancedLoggingService? enhancedLoggingService = null, + IActivityAuditService? activityAuditService = null) { this.Logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.EnhancedLoggingService = enhancedLoggingService; + this.ActivityAuditService = activityAuditService; } /// @@ -233,6 +238,11 @@ protected async Task LogUserActionAsync(string action, string details, string? c { await this.EnhancedLoggingService.LogUserActionAsync(action, details, context); } + + if (this.ActivityAuditService != null) + { + await this.ActivityAuditService.LogUserActionAsync(action, details, context); + } } catch (Exception ex) { diff --git a/ViewModels/LogViewerViewModel.cs b/ViewModels/LogViewerViewModel.cs index 0d43191..021092c 100644 --- a/ViewModels/LogViewerViewModel.cs +++ b/ViewModels/LogViewerViewModel.cs @@ -1,38 +1,39 @@ -/* - * ThreadPilot - Advanced Windows Process and Power Plan Manager - * Copyright (C) 2025 Prime Build - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, version 3 only. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ -using System; -using System.Collections.ObjectModel; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -using System.Windows.Input; -using CommunityToolkit.Mvvm.ComponentModel; -using CommunityToolkit.Mvvm.Input; -using Microsoft.Extensions.Logging; -using ThreadPilot.Models; -using ThreadPilot.Services; - -namespace ThreadPilot.ViewModels -{ - /// - /// ViewModel for the log viewer and management interface. - /// - public partial class LogViewerViewModel : ObservableObject - { +/* + * ThreadPilot - Advanced Windows Process and Power Plan Manager + * Copyright (C) 2025 Prime Build + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, version 3 only. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +using System; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Input; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using Microsoft.Extensions.Logging; +using ThreadPilot.Models; +using ThreadPilot.Services; + +namespace ThreadPilot.ViewModels +{ + /// + /// ViewModel for the log viewer and management interface. + /// + public partial class LogViewerViewModel : ObservableObject + { + private readonly IActivityAuditService activityAuditService; private readonly IEnhancedLoggingService loggingService; private readonly IApplicationSettingsService settingsService; private readonly ILogger logger; @@ -81,344 +82,435 @@ public partial class LogViewerViewModel : ObservableObject [ObservableProperty] private int refreshIntervalSeconds = 30; - - public ObservableCollection AvailableCategories { get; } = new() - { - "All", "PowerPlan", "ProcessMonitoring", "GameBoost", "UserAction", "System", "Error", "Performance" - }; - - public ObservableCollection AvailableLogLevels { get; } = new() - { - LogLevel.Trace, LogLevel.Debug, LogLevel.Information, LogLevel.Warning, LogLevel.Error, LogLevel.Critical - }; - - public ICommand RefreshLogsCommand { get; } - - public ICommand ClearLogsCommand { get; } - - public ICommand ExportLogsCommand { get; } - - public ICommand CleanupOldLogsCommand { get; } - - public ICommand SaveSettingsCommand { get; } - - public ICommand OpenLogDirectoryCommand { get; } - - public ICommand CopyLogEntryCommand { get; } - - public LogViewerViewModel( - IEnhancedLoggingService loggingService, - IApplicationSettingsService settingsService, - ILogger logger) - { + + public ObservableCollection AvailableCategories { get; } = new() + { + "All", + "Process", + "Affinity", + "Priority", + "Memory Priority", + "Rules", + "Power Plans", + "Settings", + "Tweaks", + "Optimization", + "Diagnostics", + "Safety", + }; + + public ObservableCollection AvailableLogLevels { get; } = new() + { + LogLevel.Trace, LogLevel.Debug, LogLevel.Information, LogLevel.Warning, LogLevel.Error, LogLevel.Critical + }; + + public ICommand RefreshLogsCommand { get; } + + public ICommand ClearLogsCommand { get; } + + public ICommand ExportLogsCommand { get; } + + public ICommand CleanupOldLogsCommand { get; } + + public ICommand SaveSettingsCommand { get; } + + public ICommand OpenLogDirectoryCommand { get; } + + public ICommand CopyLogEntryCommand { get; } + + public LogViewerViewModel( + IActivityAuditService activityAuditService, + IEnhancedLoggingService loggingService, + IApplicationSettingsService settingsService, + ILogger logger) + { + this.activityAuditService = activityAuditService ?? throw new ArgumentNullException(nameof(activityAuditService)); this.loggingService = loggingService ?? throw new ArgumentNullException(nameof(loggingService)); this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); - - // Initialize commands - this.RefreshLogsCommand = new AsyncRelayCommand(this.RefreshLogsAsync); - this.ClearLogsCommand = new AsyncRelayCommand(this.ClearLogsAsync); - this.ExportLogsCommand = new AsyncRelayCommand(this.ExportLogsAsync); - this.CleanupOldLogsCommand = new AsyncRelayCommand(this.CleanupOldLogsAsync); - this.SaveSettingsCommand = new AsyncRelayCommand(this.SaveSettingsAsync); - this.OpenLogDirectoryCommand = new RelayCommand(this.OpenLogDirectory); - this.CopyLogEntryCommand = new RelayCommand(this.CopyLogEntry); - - // Load initial settings - this.LoadSettings(); - - // Start auto-refresh if enabled + + // Initialize commands + this.RefreshLogsCommand = new AsyncRelayCommand(this.RefreshLogsAsync); + this.ClearLogsCommand = new AsyncRelayCommand(this.ClearLogsAsync); + this.ExportLogsCommand = new AsyncRelayCommand(this.ExportLogsAsync); + this.CleanupOldLogsCommand = new AsyncRelayCommand(this.CleanupOldLogsAsync); + this.SaveSettingsCommand = new AsyncRelayCommand(this.SaveSettingsAsync); + this.OpenLogDirectoryCommand = new RelayCommand(this.OpenLogDirectory); + this.CopyLogEntryCommand = new RelayCommand(this.CopyLogEntry); + + // Load initial settings + this.LoadSettings(); + this.activityAuditService.EntryAdded += this.OnActivityEntryAdded; + + // Start auto-refresh if enabled if (this.autoRefresh) - { - this.StartAutoRefresh(); - } - } - - public async Task InitializeAsync() - { - try - { - this.IsLoading = true; - this.StatusMessage = "Loading logs..."; - - await this.RefreshLogsAsync(); - await this.RefreshStatisticsAsync(); - - this.StatusMessage = "Ready"; - } - catch (Exception ex) - { + { + this.StartAutoRefresh(); + } + } + + public async Task InitializeAsync() + { + try + { + this.IsLoading = true; + this.StatusMessage = "Loading activity..."; + + await this.RefreshLogsAsync(); + await this.RefreshStatisticsAsync(); + + this.StatusMessage = "Ready"; + } + catch (Exception ex) + { this.logger.LogError(ex, "Failed to initialize log viewer"); - this.StatusMessage = $"Error: {ex.Message}"; - } - finally - { - this.IsLoading = false; - } - } - - private async Task RefreshLogsAsync() - { - try - { - this.IsLoading = true; - this.StatusMessage = "Refreshing logs..."; - - var logEntries = await this.loggingService.GetLogEntriesAsync(this.FromDate, this.ToDate); - - // Filter by category and log level - var filteredEntries = logEntries.Where(entry => - { - var categoryMatch = this.SelectedCategory == "All" || entry.Category == this.SelectedCategory; - var levelMatch = entry.Level >= this.SelectedLogLevel; - var searchMatch = string.IsNullOrEmpty(this.SearchText) || - entry.Message.Contains(this.SearchText, StringComparison.OrdinalIgnoreCase) || - entry.Category.Contains(this.SearchText, StringComparison.OrdinalIgnoreCase); - - return categoryMatch && levelMatch && searchMatch; - }).ToList(); - - // Convert to display models - var displayModels = filteredEntries.Select(entry => new LogEntryDisplayModel - { - Timestamp = entry.Timestamp, - Level = entry.Level, - Category = entry.Category, - Message = entry.Message, - Exception = entry.Exception, - Properties = entry.Properties, - CorrelationId = entry.CorrelationId - }).ToList(); - - // PERFORMANCE OPTIMIZATION: Replace collection instead of Clear() + Add() loop - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => - { - this.LogEntries = new ObservableCollection(displayModels); - this.StatusMessage = $"Loaded {this.LogEntries.Count} log entries"; - }); - } - catch (Exception ex) - { + this.StatusMessage = $"Error: {ex.Message}"; + } + finally + { + this.IsLoading = false; + } + } + + private async Task RefreshLogsAsync() + { + try + { + this.IsLoading = true; + this.StatusMessage = "Refreshing activity..."; + + var logEntries = await this.activityAuditService.GetEntriesAsync(this.FromDate, this.ToDate); + + // Filter by category and log level + var filteredEntries = logEntries.Where(entry => + this.ShouldDisplay(entry)).ToList(); + + // Convert to display models + var displayModels = filteredEntries.Select(ToDisplayModel).ToList(); + + // PERFORMANCE OPTIMIZATION: Replace collection instead of Clear() + Add() loop + await InvokeOnUiAsync(() => + { + this.LogEntries = new ObservableCollection(displayModels); + this.StatusMessage = $"Loaded {this.LogEntries.Count} log entries"; + }); + } + catch (Exception ex) + { this.logger.LogError(ex, "Failed to refresh logs"); - this.StatusMessage = $"Error refreshing logs: {ex.Message}"; - } - finally - { - this.IsLoading = false; - } - } - - private async Task RefreshStatisticsAsync() - { - try - { + this.StatusMessage = $"Error refreshing activity: {ex.Message}"; + } + finally + { + this.IsLoading = false; + } + } + + private async Task RefreshStatisticsAsync() + { + try + { this.LogStatistics = await this.loggingService.GetLogStatisticsAsync(); - } - catch (Exception ex) - { + } + catch (Exception ex) + { this.logger.LogError(ex, "Failed to refresh log statistics"); - } - } - - private async Task ClearLogsAsync() - { - try - { - this.LogEntries.Clear(); - this.StatusMessage = "Log display cleared"; - await this.loggingService.LogUserActionAsync("LogsCleared", "User cleared log display"); - } - catch (Exception ex) - { + } + } + + private async Task ClearLogsAsync() + { + try + { + this.LogEntries.Clear(); + this.StatusMessage = "Activity display cleared"; + } + catch (Exception ex) + { this.logger.LogError(ex, "Failed to clear logs"); - this.StatusMessage = $"Error clearing logs: {ex.Message}"; - } - } - - private async Task ExportLogsAsync() - { - try - { - this.IsLoading = true; - this.StatusMessage = "Exporting logs..."; - - var exportPath = await this.loggingService.ExportLogsAsync(this.FromDate, this.ToDate); - this.StatusMessage = $"Logs exported to: {exportPath}"; - - await this.loggingService.LogUserActionAsync( - "LogsExported", - $"Logs exported to {exportPath}", - $"DateRange: {this.FromDate:yyyy-MM-dd} to {this.ToDate:yyyy-MM-dd}"); - } - catch (Exception ex) - { + this.StatusMessage = $"Error clearing logs: {ex.Message}"; + } + } + + private async Task ExportLogsAsync() + { + try + { + this.IsLoading = true; + this.StatusMessage = "Exporting activity..."; + + var entries = await this.activityAuditService.GetEntriesAsync(this.FromDate, this.ToDate); + var exportPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.Desktop), + $"ThreadPilot_Activity_{DateTime.Now:yyyyMMdd_HHmmss}.txt"); + var exportLines = entries.Select(e => + $"{e.Timestamp:yyyy-MM-dd HH:mm:ss.fff} [{e.Severity}] {e.Category}: {e.Message}" + + (string.IsNullOrWhiteSpace(e.Details) ? string.Empty : $" ({e.Details})")); + await File.WriteAllLinesAsync(exportPath, exportLines); + this.StatusMessage = $"Activity exported to: {exportPath}"; + + await this.activityAuditService.LogInfoAsync( + "Diagnostics", + $"Activity exported to {Path.GetFileName(exportPath)}", + $"DateRange: {this.FromDate:yyyy-MM-dd} to {this.ToDate:yyyy-MM-dd}"); + } + catch (Exception ex) + { this.logger.LogError(ex, "Failed to export logs"); - this.StatusMessage = $"Error exporting logs: {ex.Message}"; - } - finally - { - this.IsLoading = false; - } - } - - private async Task CleanupOldLogsAsync() - { - try - { - this.IsLoading = true; - this.StatusMessage = "Cleaning up old logs..."; - + this.StatusMessage = $"Error exporting logs: {ex.Message}"; + } + finally + { + this.IsLoading = false; + } + } + + private async Task CleanupOldLogsAsync() + { + try + { + this.IsLoading = true; + this.StatusMessage = "Cleaning up old logs..."; + await this.loggingService.CleanupOldLogsAsync(); - await this.RefreshStatisticsAsync(); - - this.StatusMessage = "Old logs cleaned up successfully"; - await this.loggingService.LogUserActionAsync("LogsCleanup", "User initiated log cleanup"); - } - catch (Exception ex) - { + await this.RefreshStatisticsAsync(); + + this.StatusMessage = "Old diagnostic log files cleaned up successfully"; + } + catch (Exception ex) + { this.logger.LogError(ex, "Failed to cleanup old logs"); - this.StatusMessage = $"Error cleaning up logs: {ex.Message}"; - } - finally - { - this.IsLoading = false; - } - } - - private async Task SaveSettingsAsync() - { - try - { + this.StatusMessage = $"Error cleaning up logs: {ex.Message}"; + } + finally + { + this.IsLoading = false; + } + } + + private async Task SaveSettingsAsync() + { + try + { await this.loggingService.UpdateConfigurationAsync(this.EnableDebugLogging, this.MaxLogFileSizeMb, this.LogRetentionDays); - - this.StatusMessage = "Logging settings saved successfully"; - await this.loggingService.LogUserActionAsync( - "LoggingSettingsChanged", - $"Debug: {this.EnableDebugLogging}, MaxSize: {this.MaxLogFileSizeMb}MB, Retention: {this.LogRetentionDays} days"); - } - catch (Exception ex) - { + + this.StatusMessage = "Diagnostic logging settings saved successfully"; + await this.activityAuditService.LogInfoAsync( + "Diagnostics", + "Diagnostic logging settings saved", + $"Debug: {this.EnableDebugLogging}, MaxSize: {this.MaxLogFileSizeMb}MB, Retention: {this.LogRetentionDays} days"); + } + catch (Exception ex) + { this.logger.LogError(ex, "Failed to save logging settings"); - this.StatusMessage = $"Error saving settings: {ex.Message}"; - } - } - - private void OpenLogDirectory() - { - try - { + this.StatusMessage = $"Error saving settings: {ex.Message}"; + } + } + + private void OpenLogDirectory() + { + try + { var logDirectory = this.loggingService.LogDirectoryPath; - if (Directory.Exists(logDirectory)) - { - System.Diagnostics.Process.Start("explorer.exe", logDirectory); - } - else - { - this.StatusMessage = "Log directory not found"; - } - } - catch (Exception ex) - { + if (Directory.Exists(logDirectory)) + { + System.Diagnostics.Process.Start("explorer.exe", logDirectory); + } + else + { + this.StatusMessage = "Log directory not found"; + } + } + catch (Exception ex) + { this.logger.LogError(ex, "Failed to open log directory"); - this.StatusMessage = $"Error opening log directory: {ex.Message}"; - } - } - - private void CopyLogEntry(LogEntryDisplayModel? logEntry) - { - if (logEntry == null) - { - return; - } - - try - { - var logText = $"[{logEntry.Timestamp:yyyy-MM-dd HH:mm:ss.fff}] [{logEntry.Level}] {logEntry.Category}: {logEntry.Message}"; - if (!string.IsNullOrEmpty(logEntry.Exception)) - { - logText += $"\nException: {logEntry.Exception}"; - } - - System.Windows.Clipboard.SetText(logText); - this.StatusMessage = "Log entry copied to clipboard"; - } - catch (Exception ex) - { + this.StatusMessage = $"Error opening log directory: {ex.Message}"; + } + } + + private void CopyLogEntry(LogEntryDisplayModel? logEntry) + { + if (logEntry == null) + { + return; + } + + try + { + var logText = $"[{logEntry.Timestamp:yyyy-MM-dd HH:mm:ss.fff}] [{logEntry.Status}] {logEntry.Category}: {logEntry.Message}"; + if (!string.IsNullOrEmpty(logEntry.Details)) + { + logText += $"\nDetails: {logEntry.Details}"; + } + + System.Windows.Clipboard.SetText(logText); + this.StatusMessage = "Log entry copied to clipboard"; + } + catch (Exception ex) + { this.logger.LogError(ex, "Failed to copy log entry to clipboard"); - this.StatusMessage = "Failed to copy log entry"; - } - } - - private void LoadSettings() - { + this.StatusMessage = "Failed to copy log entry"; + } + } + + private void LoadSettings() + { var settings = this.settingsService.Settings; - this.EnableDebugLogging = settings.EnableDebugLogging; - this.MaxLogFileSizeMb = settings.MaxLogFileSizeMb; - this.LogRetentionDays = settings.LogRetentionDays; - } - - private void StartAutoRefresh() - { - // Implementation for auto-refresh timer would go here - // For now, we'll keep it simple without the timer - } - - partial void OnSearchTextChanged(string value) - { - // Trigger refresh when search text changes - marshal to UI thread to prevent cross-thread access exceptions - _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => await this.RefreshLogsAsync()); - } - - partial void OnSelectedCategoryChanged(string value) - { - // Trigger refresh when category changes - marshal to UI thread to prevent cross-thread access exceptions - _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => await this.RefreshLogsAsync()); - } - - partial void OnSelectedLogLevelChanged(LogLevel value) - { - // Trigger refresh when log level changes - marshal to UI thread to prevent cross-thread access exceptions - _ = System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => await this.RefreshLogsAsync()); - } - } - - /// - /// Display model for log entries in the UI. - /// - public class LogEntryDisplayModel - { - public DateTime Timestamp { get; set; } - - public LogLevel Level { get; set; } - - public string Category { get; set; } = string.Empty; - - public string Message { get; set; } = string.Empty; - - public string? Exception { get; set; } - - public Dictionary Properties { get; set; } = new(); - - public string? CorrelationId { get; set; } - - public string LevelColor => this.Level switch - { - LogLevel.Critical => "#FF0000", - LogLevel.Error => "#FF4444", - LogLevel.Warning => "#FFA500", - LogLevel.Information => "#0066CC", - LogLevel.Debug => "#808080", - LogLevel.Trace => "#C0C0C0", - _ => "#000000" - }; - - public string FormattedTimestamp => this.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff"); - - public string ShortMessage => this.Message.Length > 100 ? this.Message.Substring(0, 100) + "..." : this.Message; - - public bool HasException => !string.IsNullOrEmpty(this.Exception); - - public bool HasProperties => this.Properties.Any(); - } -} - + this.EnableDebugLogging = settings.EnableDebugLogging; + this.MaxLogFileSizeMb = settings.MaxLogFileSizeMb; + this.LogRetentionDays = settings.LogRetentionDays; + } + + private void StartAutoRefresh() + { + // Implementation for auto-refresh timer would go here + // For now, we'll keep it simple without the timer + } + + private void OnActivityEntryAdded(object? sender, ActivityAuditEntry entry) + { + if (!this.ShouldDisplay(entry)) + { + return; + } + + _ = InvokeOnUiAsync(() => + { + this.LogEntries.Insert(0, ToDisplayModel(entry)); + while (this.LogEntries.Count > 1000) + { + this.LogEntries.RemoveAt(this.LogEntries.Count - 1); + } + + this.StatusMessage = $"Loaded {this.LogEntries.Count} activity entries"; + }); + } + + private bool ShouldDisplay(ActivityAuditEntry entry) + { + var categoryMatch = this.SelectedCategory == "All" || entry.Category == this.SelectedCategory; + var levelMatch = ToLogLevel(entry.Severity) >= this.SelectedLogLevel; + var searchMatch = string.IsNullOrEmpty(this.SearchText) || + entry.Message.Contains(this.SearchText, StringComparison.OrdinalIgnoreCase) || + entry.Category.Contains(this.SearchText, StringComparison.OrdinalIgnoreCase) || + (entry.Details?.Contains(this.SearchText, StringComparison.OrdinalIgnoreCase) ?? false); + + return categoryMatch && levelMatch && searchMatch; + } + + private static LogEntryDisplayModel ToDisplayModel(ActivityAuditEntry entry) => + new() + { + Timestamp = entry.Timestamp, + Level = ToLogLevel(entry.Severity), + AuditSeverity = entry.Severity, + Category = entry.Category, + Message = entry.Message, + Details = entry.Details, + }; + + partial void OnSearchTextChanged(string value) + { + // Trigger refresh when search text changes - marshal to UI thread to prevent cross-thread access exceptions + _ = InvokeOnUiAsync(async () => await this.RefreshLogsAsync()); + } + + partial void OnSelectedCategoryChanged(string value) + { + // Trigger refresh when category changes - marshal to UI thread to prevent cross-thread access exceptions + _ = InvokeOnUiAsync(async () => await this.RefreshLogsAsync()); + } + + partial void OnSelectedLogLevelChanged(LogLevel value) + { + // Trigger refresh when log level changes - marshal to UI thread to prevent cross-thread access exceptions + _ = InvokeOnUiAsync(async () => await this.RefreshLogsAsync()); + } + + private static Task InvokeOnUiAsync(Action action) + { + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher == null || dispatcher.CheckAccess()) + { + action(); + return Task.CompletedTask; + } + + return dispatcher.InvokeAsync(action).Task; + } + + private static Task InvokeOnUiAsync(Func action) + { + var dispatcher = System.Windows.Application.Current?.Dispatcher; + if (dispatcher == null || dispatcher.CheckAccess()) + { + return action(); + } + + return dispatcher.InvokeAsync(action).Task.Unwrap(); + } + + private static LogLevel ToLogLevel(ActivityAuditSeverity severity) => + severity switch + { + ActivityAuditSeverity.Error => LogLevel.Error, + ActivityAuditSeverity.Warning => LogLevel.Warning, + _ => LogLevel.Information, + }; + } + + /// + /// Display model for log entries in the UI. + /// + public class LogEntryDisplayModel + { + public DateTime Timestamp { get; set; } + + public LogLevel Level { get; set; } + + public ActivityAuditSeverity? AuditSeverity { get; set; } + + public string Category { get; set; } = string.Empty; + + public string Message { get; set; } = string.Empty; + + public string? Exception { get; set; } + + public string? Details { get; set; } + + public Dictionary Properties { get; set; } = new(); + + public string? CorrelationId { get; set; } + + public string LevelColor => this.AuditSeverity switch + { + ActivityAuditSeverity.Error => "#FF4444", + ActivityAuditSeverity.Warning => "#FFA500", + ActivityAuditSeverity.Success => "#107C10", + ActivityAuditSeverity.Info => "#0066CC", + _ => this.Level switch + { + LogLevel.Critical => "#FF0000", + LogLevel.Error => "#FF4444", + LogLevel.Warning => "#FFA500", + LogLevel.Information => "#0066CC", + LogLevel.Debug => "#808080", + LogLevel.Trace => "#C0C0C0", + _ => "#000000" + }, + }; + + public string Status => this.AuditSeverity?.ToString() ?? this.Level.ToString(); + + public string FormattedTimestamp => this.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff"); + + public string ShortMessage => this.Message.Length > 100 ? this.Message.Substring(0, 100) + "..." : this.Message; + + public bool HasException => !string.IsNullOrEmpty(this.Exception); + + public bool HasDetails => !string.IsNullOrEmpty(this.Details); + + public bool HasProperties => this.Properties.Any(); + } +} + diff --git a/ViewModels/MainWindowViewModel.cs b/ViewModels/MainWindowViewModel.cs index 06e8050..ad4a2e0 100644 --- a/ViewModels/MainWindowViewModel.cs +++ b/ViewModels/MainWindowViewModel.cs @@ -66,8 +66,9 @@ public MainWindowViewModel( IProcessMonitorManagerService? processMonitorManagerService = null, INotificationService? notificationService = null, IElevationService? elevationService = null, - ISecurityService? securityService = null) - : base(logger, enhancedLoggingService) + ISecurityService? securityService = null, + IActivityAuditService? activityAuditService = null) + : base(logger, enhancedLoggingService, activityAuditService) { this.processMonitorManagerService = processMonitorManagerService; this.notificationService = notificationService; diff --git a/ViewModels/PerformanceViewModel.cs b/ViewModels/PerformanceViewModel.cs index c23cef9..1697f24 100644 --- a/ViewModels/PerformanceViewModel.cs +++ b/ViewModels/PerformanceViewModel.cs @@ -178,8 +178,9 @@ public PerformanceViewModel( IProcessMonitorManagerService processMonitorManagerService, ISystemTweaksService systemTweaksService, ILogger logger, - IEnhancedLoggingService? enhancedLoggingService = null) - : base(logger, enhancedLoggingService) + IEnhancedLoggingService? enhancedLoggingService = null, + IActivityAuditService? activityAuditService = null) + : base(logger, enhancedLoggingService, activityAuditService) { this.performanceService = performanceService; this.processService = processService; @@ -204,12 +205,17 @@ public override async Task InitializeAsync() } public async Task ActivateDiagnosticsAsync() + { + await this.ActivateDiagnosticsCoreAsync(auditActivity: false); + } + + private async Task ActivateDiagnosticsCoreAsync(bool auditActivity) { try { this.SetStatus("Loading optional diagnostics..."); - var snapshotLoaded = await this.RefreshMetricsSnapshotAsync(); + var snapshotLoaded = await this.RefreshMetricsSnapshotAsync(auditActivity); await this.LoadHistoricalDataAsync(); this.diagnosticsActivated = true; @@ -325,14 +331,14 @@ private async Task RefreshMetricsAsync() { if (!this.diagnosticsActivated) { - await this.ActivateDiagnosticsAsync(); + await this.ActivateDiagnosticsCoreAsync(auditActivity: true); return; } - await this.RefreshMetricsSnapshotAsync(); + await this.RefreshMetricsSnapshotAsync(auditActivity: true); } - private async Task RefreshMetricsSnapshotAsync() + private async Task RefreshMetricsSnapshotAsync(bool auditActivity) { try { @@ -345,13 +351,19 @@ private async Task RefreshMetricsSnapshotAsync() this.LastManualRefreshText = $"Refreshed at {DateTime.Now:HH:mm:ss}"; this.SetStatus("Performance snapshot refreshed", false); - await this.LogUserActionAsync("OptimizationSnapshotRefreshed", "Performance snapshot refreshed"); + if (auditActivity) + { + await this.LogUserActionAsync("OptimizationSnapshotRefreshed", "Performance snapshot refreshed"); + } return true; } catch (Exception ex) { this.SetError("Failed to refresh performance snapshot", ex); - await this.LogUserActionAsync("OptimizationActionFailed", $"Failed to refresh performance snapshot: {ex.Message}"); + if (auditActivity) + { + await this.LogUserActionAsync("OptimizationActionFailed", $"Failed to refresh performance snapshot: {ex.Message}"); + } return false; } } diff --git a/ViewModels/PowerPlanViewModel.cs b/ViewModels/PowerPlanViewModel.cs index 813aa79..eceb7b2 100644 --- a/ViewModels/PowerPlanViewModel.cs +++ b/ViewModels/PowerPlanViewModel.cs @@ -55,8 +55,9 @@ public partial class PowerPlanViewModel : BaseViewModel public PowerPlanViewModel( ILogger logger, IPowerPlanService powerPlanService, - IEnhancedLoggingService? enhancedLoggingService = null) - : base(logger, enhancedLoggingService) + IEnhancedLoggingService? enhancedLoggingService = null, + IActivityAuditService? activityAuditService = null) + : base(logger, enhancedLoggingService, activityAuditService) { this.powerPlanService = powerPlanService; this.SetupRefreshTimer(); @@ -84,7 +85,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(async () => { if (!this.isAutoRefreshPaused) { - await this.RefreshPowerPlansCommand.ExecuteAsync(null); + await this.RefreshPowerPlansCoreAsync(reportStatus: false); } }); } @@ -120,7 +121,7 @@ public void ResumeAutoRefresh(bool refreshImmediately = true) { try { - await this.RefreshPowerPlansCommand.ExecuteAsync(null); + await this.RefreshPowerPlansCoreAsync(reportStatus: false); } catch (Exception ex) { diff --git a/ViewModels/ProcessViewModel.Behaviors.partial.cs b/ViewModels/ProcessViewModel.Behaviors.partial.cs index a047801..f7feec5 100644 --- a/ViewModels/ProcessViewModel.Behaviors.partial.cs +++ b/ViewModels/ProcessViewModel.Behaviors.partial.cs @@ -685,7 +685,7 @@ private async Task RefreshProcesses() ? await this.processService.GetActiveApplicationsAsync() : await this.processService.GetProcessesAsync(); - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + await InvokeOnUiAsync(() => { var deltaResult = ProcessListDeltaUpdater.ApplyDelta( this.Processes, @@ -712,7 +712,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => } catch (Exception ex) { - await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => + await InvokeOnUiAsync(() => { this.SetStatus($"Error refreshing processes: {ex.Message}", false); }); @@ -815,6 +815,10 @@ private async Task ApplyAffinityAndSaveAsRule(ProcessModel? process) if (!applyResult.Success) { this.SetContextError(applyResult.Message); + await this.LogUserActionAsync( + "ProcessAffinityFailed", + applyResult.Message, + $"Process: {process.Name}, PID: {process.ProcessId}, RequestedMask: 0x{applyResult.RequestedMask:X}"); await this.UpdateSelectedProcessSummaryAsync(process); return; } @@ -1334,8 +1338,24 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => [RelayCommand] private async Task SetPriority(ProcessPriorityClass priority) { - if (this.SelectedProcess == null) + var selectedProcess = this.SelectedProcess; + if (selectedProcess == null) + { + return; + } + + if (ProcessPriorityGuardrails.IsBlocked(priority)) { + var message = ProcessOperationUserMessages.RealtimePriorityBlocked; + await InvokeOnUiAsync(() => + { + this.SetCriticalStatus(message); + }); + _ = this.notificationService.ShowNotificationAsync("Priority blocked", message, NotificationType.Warning); + await this.LogUserActionAsync( + "ProcessPriorityBlocked", + message, + $"Process: {selectedProcess.Name}, PID: {selectedProcess.ProcessId}, Priority: {priority}"); return; } @@ -1343,14 +1363,14 @@ private async Task SetPriority(ProcessPriorityClass priority) { await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { - this.SetStatus($"Setting priority for {this.SelectedProcess.Name} to {priority}..."); + this.SetStatus($"Setting priority for {selectedProcess.Name} to {priority}..."); }); // Apply the priority change - await this.processService.SetProcessPriority(this.SelectedProcess, priority); + await this.processService.SetProcessPriority(selectedProcess, priority); // Immediately refresh the process to get the actual system state - await this.processService.RefreshProcessInfo(this.SelectedProcess); + await this.processService.RefreshProcessInfo(selectedProcess); await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { @@ -1358,7 +1378,7 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => this.OnPropertyChanged(nameof(this.SelectedProcess)); // Verify the priority was set correctly - if (this.SelectedProcess.Priority == priority) + if (selectedProcess.Priority == priority) { var warning = ProcessPriorityGuardrails.GetWarning(priority); if (!string.IsNullOrWhiteSpace(warning)) @@ -1368,16 +1388,20 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => } else { - this.SetStatus($"Priority applied successfully to {this.SelectedProcess.Name}: {priority}.", false); - _ = this.notificationService.ShowNotificationAsync("Priority applied", $"{this.SelectedProcess.Name}: {priority}", NotificationType.Success); + this.SetStatus($"Priority applied successfully to {selectedProcess.Name}: {priority}.", false); + _ = this.notificationService.ShowNotificationAsync("Priority applied", $"{selectedProcess.Name}: {priority}", NotificationType.Success); } } else { - this.SetStatus($"Priority adjusted by system for {this.SelectedProcess.Name} to {this.SelectedProcess.Priority}.", false); - _ = this.notificationService.ShowNotificationAsync("Priority adjusted", $"{this.SelectedProcess.Name}: {this.SelectedProcess.Priority}", NotificationType.Warning); + this.SetStatus($"Priority adjusted by system for {selectedProcess.Name} to {selectedProcess.Priority}.", false); + _ = this.notificationService.ShowNotificationAsync("Priority adjusted", $"{selectedProcess.Name}: {selectedProcess.Priority}", NotificationType.Warning); } }); + await this.LogUserActionAsync( + "ProcessPriorityChanged", + $"CPU priority changed for {selectedProcess.Name}: {priority}", + $"PID: {selectedProcess.ProcessId}"); } catch (Exception ex) { @@ -1402,11 +1426,15 @@ await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { this.SetCriticalStatus($"Error setting priority: {message}"); }); + await this.LogUserActionAsync( + message == ProcessOperationUserMessages.RealtimePriorityBlocked ? "ProcessPriorityBlocked" : "ProcessPriorityChangeFailed", + message, + $"Process: {selectedProcess.Name}, PID: {selectedProcess.ProcessId}, Priority: {priority}"); // Try to refresh process info even if setting failed, to show current state try { - await this.processService.RefreshProcessInfo(this.SelectedProcess); + await this.processService.RefreshProcessInfo(selectedProcess); await System.Windows.Application.Current.Dispatcher.InvokeAsync(() => { this.OnPropertyChanged(nameof(this.SelectedProcess)); @@ -1513,11 +1541,20 @@ private async Task RefreshContextProcessInfo(ProcessModel? process) { await this.processService.RefreshProcessInfo(process); this.SetStatus($"Process info refreshed for {process.Name}.", false); + await this.LogUserActionAsync( + "ProcessInfoRefreshed", + $"Process info refreshed for {process.Name}.", + $"PID: {process.ProcessId}"); await this.UpdateSelectedProcessSummaryAsync(process); } catch (Exception ex) { - this.SetContextError(MapProcessOperationException(ex)); + var message = MapProcessOperationException(ex); + this.SetContextError(message); + await this.LogUserActionAsync( + "ProcessInfoRefreshFailed", + message, + $"Process: {process.Name}, PID: {process.ProcessId}"); await this.TryRefreshContextProcessSummaryAsync(process); } } @@ -1533,7 +1570,12 @@ private async Task OpenContextExecutableLocation(ProcessModel? process) var path = process.ExecutablePath; if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) { - this.SetContextError($"Executable path is unavailable for {process.Name}."); + var message = $"Executable path is unavailable for {process.Name}."; + this.SetContextError(message); + await this.LogUserActionAsync( + "ProcessExecutableOpenFailed", + message, + $"Process: {process.Name}, PID: {process.ProcessId}"); await this.UpdateSelectedProcessSummaryAsync(process); return; } @@ -1542,11 +1584,20 @@ private async Task OpenContextExecutableLocation(ProcessModel? process) { this.executableLocationOpener(path); this.SetStatus($"Opened executable location for {process.Name}.", false); + await this.LogUserActionAsync( + "ProcessExecutableLocationOpened", + $"Opened executable location for {process.Name}.", + path); await this.UpdateSelectedProcessSummaryAsync(process); } catch (Exception ex) { - this.SetContextError($"Could not open executable location: {ex.Message}"); + var message = $"Could not open executable location: {ex.Message}"; + this.SetContextError(message); + await this.LogUserActionAsync( + "ProcessExecutableOpenFailed", + message, + $"Process: {process.Name}, PID: {process.ProcessId}"); await this.UpdateSelectedProcessSummaryAsync(process); } } @@ -1577,11 +1628,20 @@ private async Task CopyContextProcessInfo(ProcessModel? process) { this.clipboardSetter(builder.ToString().TrimEnd()); this.SetStatus($"Copied process info for {process.Name}.", false); + await this.LogUserActionAsync( + "ProcessInfoCopied", + $"Copied process info for {process.Name}.", + $"PID: {process.ProcessId}"); await this.UpdateSelectedProcessSummaryAsync(process); } catch (Exception ex) { - this.SetContextError($"Could not copy process info: {ex.Message}"); + var message = $"Could not copy process info: {ex.Message}"; + this.SetContextError(message); + await this.LogUserActionAsync( + "ProcessInfoCopyFailed", + message, + $"Process: {process.Name}, PID: {process.ProcessId}"); await this.UpdateSelectedProcessSummaryAsync(process); } } diff --git a/ViewModels/ProcessViewModel.cs b/ViewModels/ProcessViewModel.cs index ffa3c0c..d556d68 100644 --- a/ViewModels/ProcessViewModel.cs +++ b/ViewModels/ProcessViewModel.cs @@ -186,13 +186,14 @@ public ProcessViewModel( IProcessAffinityApplyCoordinator? processAffinityApplyCoordinator = null, ICpuTopologyProvider? cpuTopologyProvider = null, IEnhancedLoggingService? enhancedLoggingService = null, + IActivityAuditService? activityAuditService = null, IProcessMemoryPriorityService? memoryPriorityService = null, IPersistentProcessRuleStore? persistentRuleStore = null, IPersistentProcessRuleMatcher? persistentRuleMatcher = null, IProcessRuleCreationService? processRuleCreationService = null, Action? clipboardSetter = null, Action? executableLocationOpener = null) - : base(logger, enhancedLoggingService) + : base(logger, enhancedLoggingService, activityAuditService) { this.processService = processService ?? throw new ArgumentNullException(nameof(processService)); this.processFilterService = processFilterService ?? throw new ArgumentNullException(nameof(processFilterService)); diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs index 7aa93e1..adbebd5 100644 --- a/ViewModels/SettingsViewModel.cs +++ b/ViewModels/SettingsViewModel.cs @@ -106,8 +106,9 @@ public SettingsViewModel( IThemeService themeService, ISystemTrayService systemTrayService, GitHubUpdateChecker gitHubUpdateChecker, - IEnhancedLoggingService? enhancedLoggingService = null) - : base(logger, enhancedLoggingService) + IEnhancedLoggingService? enhancedLoggingService = null, + IActivityAuditService? activityAuditService = null) + : base(logger, enhancedLoggingService, activityAuditService) { this.settingsService = settingsService ?? throw new ArgumentNullException(nameof(settingsService)); this.notificationService = notificationService ?? throw new ArgumentNullException(nameof(notificationService)); diff --git a/ViewModels/SystemTweaksViewModel.cs b/ViewModels/SystemTweaksViewModel.cs index a86233d..5a86835 100644 --- a/ViewModels/SystemTweaksViewModel.cs +++ b/ViewModels/SystemTweaksViewModel.cs @@ -46,8 +46,9 @@ public SystemTweaksViewModel( ISystemTweaksService systemTweaksService, INotificationService notificationService, ILogger logger, - IEnhancedLoggingService? enhancedLoggingService = null) - : base(logger, enhancedLoggingService) + IEnhancedLoggingService? enhancedLoggingService = null, + IActivityAuditService? activityAuditService = null) + : base(logger, enhancedLoggingService, activityAuditService) { this.systemTweaksService = systemTweaksService; this.notificationService = notificationService; diff --git a/Views/LogViewerView.xaml b/Views/LogViewerView.xaml index a98df94..9b04928 100644 --- a/Views/LogViewerView.xaml +++ b/Views/LogViewerView.xaml @@ -63,8 +63,8 @@ - - + + @@ -133,9 +133,9 @@ private const string ALLCORESMASKNAME = "All Cores"; + private const string NOCORE0MASKNAME = "No Core 0"; public CoreMaskService( ILogger logger, ICpuTopologyService cpuTopologyService, IServiceProvider serviceProvider, ICpuTopologyProvider? cpuTopologyProvider = null, - CpuSelectionMigrationService? cpuSelectionMigrationService = null) + CpuSelectionMigrationService? cpuSelectionMigrationService = null, + string? masksFilePath = null) { this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); this.cpuTopologyService = cpuTopologyService ?? throw new ArgumentNullException(nameof(cpuTopologyService)); @@ -75,8 +80,18 @@ public CoreMaskService( this.cpuTopologyProvider = cpuTopologyProvider; this.cpuSelectionMigrationService = cpuSelectionMigrationService ?? new CpuSelectionMigrationService(); - StoragePaths.EnsureAppDataDirectories(); - this.masksFilePath = StoragePaths.CoreMasksFilePath; + if (string.IsNullOrWhiteSpace(masksFilePath)) + { + StoragePaths.EnsureAppDataDirectories(); + this.masksFilePath = StoragePaths.CoreMasksFilePath; + } + else + { + Directory.CreateDirectory(Path.GetDirectoryName(masksFilePath)!); + this.masksFilePath = masksFilePath; + } + + this.cpuTopologyService.TopologyDetected += this.OnTopologyDetected; } public async Task InitializeAsync() @@ -90,17 +105,57 @@ public async Task InitializeAsync() await this.LoadMasksAsync(); - // If no masks exist, create defaults if (this.AvailableMasks.Count == 0) { this.logger.LogInformation("No masks found, creating defaults..."); - await this.CreateDefaultMasksAsync(); + } + + if (await this.BackfillBuiltInDefaultMasksAsync()) + { + await this.SaveMasksAsync(); } this.initialized = true; this.logger.LogInformation("CoreMaskService initialized with {Count} masks", this.AvailableMasks.Count); } + private void OnTopologyDetected(object? sender, CpuTopologyDetectedEventArgs e) + { + if (!this.initialized || !e.DetectionSuccessful) + { + return; + } + + var dispatcher = Application.Current?.Dispatcher; + if (dispatcher != null) + { + _ = dispatcher.InvokeAsync(async () => await this.BackfillBuiltInDefaultMasksAndSaveAsync()); + return; + } + + _ = Task.Run(this.BackfillBuiltInDefaultMasksAndSaveAsync); + } + + private async Task BackfillBuiltInDefaultMasksAndSaveAsync() + { + if (Interlocked.Exchange(ref this.topologyBackfillInProgress, 1) != 0) + { + return; + } + + try + { + if (await this.BackfillBuiltInDefaultMasksAsync()) + { + await this.SaveMasksAsync(); + } + } + finally + { + Interlocked.Exchange(ref this.topologyBackfillInProgress, 0); + } + } + public async Task CreateMaskAsync(string name, string description, IEnumerable boolMask) { var mask = new CoreMask @@ -498,11 +553,25 @@ public IEnumerable GetProcessesWithMask(string maskId) public async Task CreateDefaultMasksAsync() { - int coreCount = Environment.ProcessorCount; + bool changed = await this.BackfillBuiltInDefaultMasksAsync(); + if (changed) + { + await this.SaveMasksAsync(); + } + + this.logger.LogInformation( + "Created or backfilled default masks with topology-aware presets; total masks: {Count}", + this.AvailableMasks.Count); + } + + private async Task BackfillBuiltInDefaultMasksAsync() + { var topology = this.cpuTopologyService.CurrentTopology; + int coreCount = this.ResolveLogicalCoreCount(topology); bool topologyConfident = topology?.TopologyDetectionSuccessful == true; bool hasHyperThreading = topology?.HasHyperThreading == true; bool canCreateNoSmtVariants = topologyConfident && hasHyperThreading; + bool changed = false; // Collect all default masks with their "no SMT" variants var defaultMasks = new List<(string name, List boolMask, string description)>(); @@ -518,13 +587,32 @@ public async Task CreateDefaultMasksAsync() Name = ALLCORESMASKNAME, Description = "Use all available CPU cores - baseline mask", IsDefault = true, + IsEnabled = true, }; for (int i = 0; i < coreCount; i++) { allCoresMask.BoolMask.Add(true); } - this.AvailableMasks.Add(allCoresMask); + changed |= this.AddBuiltInMaskIfMissing(allCoresMask); + + if (coreCount > 1) + { + var noCoreZeroMask = new CoreMask + { + Name = NOCORE0MASKNAME, + Description = "Use all logical CPUs except CPU 0", + IsDefault = false, + IsEnabled = true, + }; + + for (int i = 0; i < coreCount; i++) + { + noCoreZeroMask.BoolMask.Add(i != 0); + } + + changed |= this.AddBuiltInMaskIfMissing(noCoreZeroMask); + } // 2. Intel Hybrid Architecture: P-Cores, E-Cores, LPE-Cores (Arrow Lake+) if (topology != null && topology.HasIntelHybrid) @@ -634,13 +722,34 @@ public async Task CreateDefaultMasksAsync() // Add all generated masks to AvailableMasks foreach (var mask in resultMasks) { - this.AvailableMasks.Add(mask); + changed |= this.AddBuiltInMaskIfMissing(mask); } - await this.SaveMasksAsync(); - this.logger.LogInformation( - "Created {Count} default masks with topology-aware presets (including no SMT variants)", - this.AvailableMasks.Count); + await Task.CompletedTask; + return changed; + } + + private int ResolveLogicalCoreCount(CpuTopologyModel? topology) + { + if (topology?.TopologyDetectionSuccessful == true && topology.TotalLogicalCores > 0) + { + return topology.TotalLogicalCores; + } + + return Environment.ProcessorCount; + } + + private bool AddBuiltInMaskIfMissing(CoreMask mask) + { + if (this.AvailableMasks.Any(existing => + existing.Name.Equals(mask.Name, StringComparison.OrdinalIgnoreCase))) + { + return false; + } + + this.AvailableMasks.Add(mask); + this.logger.LogInformation("Backfilled built-in core mask '{Name}'", mask.Name); + return true; } /// diff --git a/Tests/ThreadPilot.Core.Tests/CoreMaskServiceTests.cs b/Tests/ThreadPilot.Core.Tests/CoreMaskServiceTests.cs new file mode 100644 index 0000000..94fa0dc --- /dev/null +++ b/Tests/ThreadPilot.Core.Tests/CoreMaskServiceTests.cs @@ -0,0 +1,225 @@ +namespace ThreadPilot.Core.Tests +{ + using System.Text.Json; + using Microsoft.Extensions.Logging.Abstractions; + using Moq; + using ThreadPilot.Models; + using ThreadPilot.Services; + + public sealed class CoreMaskServiceTests + { + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + }; + + [Fact] + public async Task InitializeAsync_WhenNoMaskFile_CreatesAllCoresAndNoCoreZero() + { + var masksFilePath = CreateTempMasksPath(); + var service = CreateService(CreateTopology(logicalCoreCount: 4), masksFilePath); + + await service.InitializeAsync(); + + Assert.Contains(service.AvailableMasks, mask => mask.Name == "All Cores"); + var noCoreZero = Assert.Single(service.AvailableMasks, mask => mask.Name == "No Core 0"); + Assert.Equal(new[] { false, true, true, true }, noCoreZero.BoolMask); + } + + [Fact] + public async Task InitializeAsync_WithSmtTopology_CreatesAllNoSmt() + { + var masksFilePath = CreateTempMasksPath(); + var service = CreateService(CreateAmdSmtTopology(physicalCoreCount: 8, threadsPerCore: 2), masksFilePath); + + await service.InitializeAsync(); + + var allNoSmt = Assert.Single(service.AvailableMasks, mask => mask.Name == "All no SMT"); + Assert.Equal(16, allNoSmt.BoolMask.Count); + Assert.Equal(8, allNoSmt.SelectedCoreCount); + Assert.Equal( + Enumerable.Range(0, 16).Select(index => index % 2 == 0), + allNoSmt.BoolMask); + } + + [Fact] + public async Task InitializeAsync_WhenExistingFileHasOnlyAllCores_BackfillsMissingBuiltIns() + { + var masksFilePath = CreateTempMasksPath(); + var existingId = "existing-all-cores"; + await WriteMasksAsync( + masksFilePath, + CreateStoredMask(existingId, "All Cores", [true, true, true, true], isDefault: true)); + var service = CreateService(CreateTopology(logicalCoreCount: 4), masksFilePath); + + await service.InitializeAsync(); + + Assert.Equal(existingId, Assert.Single(service.AvailableMasks, mask => mask.Name == "All Cores").Id); + Assert.Contains(service.AvailableMasks, mask => mask.Name == "No Core 0"); + } + + [Fact] + public async Task InitializeAsync_BackfillDoesNotDuplicateBuiltIns() + { + var masksFilePath = CreateTempMasksPath(); + await WriteMasksAsync( + masksFilePath, + CreateStoredMask("all-cores", "All Cores", [true, true, true, true], isDefault: true), + CreateStoredMask("no-core-zero", "No Core 0", [false, true, true, true])); + var service = CreateService(CreateTopology(logicalCoreCount: 4), masksFilePath); + + await service.InitializeAsync(); + await service.InitializeAsync(); + + Assert.Equal(1, service.AvailableMasks.Count(mask => mask.Name == "All Cores")); + Assert.Equal(1, service.AvailableMasks.Count(mask => mask.Name == "No Core 0")); + } + + [Fact] + public async Task InitializeAsync_BackfillPreservesUserMasks() + { + var masksFilePath = CreateTempMasksPath(); + await WriteMasksAsync( + masksFilePath, + CreateStoredMask("all-cores", "All Cores", [true, true, true, true], isDefault: true), + CreateStoredMask("custom-mask", "My Game Mask", [false, true, true, false])); + var service = CreateService(CreateTopology(logicalCoreCount: 4), masksFilePath); + + await service.InitializeAsync(); + + var customMask = Assert.Single(service.AvailableMasks, mask => mask.Id == "custom-mask"); + Assert.Equal("My Game Mask", customMask.Name); + Assert.Equal(new[] { false, true, true, false }, customMask.BoolMask); + Assert.Contains(service.AvailableMasks, mask => mask.Name == "No Core 0"); + } + + [Fact] + public async Task TopologyDetected_AfterInitialLoad_BackfillsSmtDefaults() + { + var masksFilePath = CreateTempMasksPath(); + await WriteMasksAsync( + masksFilePath, + CreateStoredMask("all-cores", "All Cores", [true, true, true, true], isDefault: true)); + CpuTopologyModel? currentTopology = null; + var topologyService = new Mock(MockBehavior.Strict); + topologyService.SetupGet(service => service.CurrentTopology).Returns(() => currentTopology); + var service = new CoreMaskService( + NullLogger.Instance, + topologyService.Object, + Mock.Of(), + masksFilePath: masksFilePath); + + await service.InitializeAsync(); + Assert.DoesNotContain(service.AvailableMasks, mask => mask.Name == "All no SMT"); + + currentTopology = CreateAmdSmtTopology(physicalCoreCount: 8, threadsPerCore: 2); + topologyService.Raise( + mock => mock.TopologyDetected += null, + new CpuTopologyDetectedEventArgs(currentTopology, successful: true)); + + Assert.True(SpinWait.SpinUntil( + () => service.AvailableMasks.Any(mask => mask.Name == "All no SMT"), + TimeSpan.FromSeconds(3))); + Assert.Equal(1, service.AvailableMasks.Count(mask => mask.Name == "All no SMT")); + } + + private static CoreMaskService CreateService(CpuTopologyModel topology, string masksFilePath) + { + var topologyService = new Mock(MockBehavior.Strict); + topologyService.SetupGet(service => service.CurrentTopology).Returns(topology); + + return new CoreMaskService( + NullLogger.Instance, + topologyService.Object, + Mock.Of(), + masksFilePath: masksFilePath); + } + + private static CpuTopologyModel CreateTopology(int logicalCoreCount) + { + var topology = new CpuTopologyModel + { + CpuBrand = "Generic CPU", + TopologyDetectionSuccessful = true, + }; + + for (var index = 0; index < logicalCoreCount; index++) + { + topology.LogicalCores.Add(new CpuCoreModel + { + LogicalCoreId = index, + PhysicalCoreId = index, + SocketId = 0, + LogicalProcessorName = $"CPU{index}", + }); + } + + return topology; + } + + private static CpuTopologyModel CreateAmdSmtTopology(int physicalCoreCount, int threadsPerCore) + { + var topology = new CpuTopologyModel + { + CpuBrand = "AMD Ryzen", + TopologyDetectionSuccessful = true, + }; + + for (var physicalCore = 0; physicalCore < physicalCoreCount; physicalCore++) + { + var firstLogicalCore = physicalCore * threadsPerCore; + for (var thread = 0; thread < threadsPerCore; thread++) + { + var logicalCore = firstLogicalCore + thread; + topology.LogicalCores.Add(new CpuCoreModel + { + LogicalCoreId = logicalCore, + PhysicalCoreId = physicalCore, + SocketId = 0, + CoreType = CpuCoreType.Zen4, + IsHyperThreaded = threadsPerCore > 1, + HyperThreadSibling = threadsPerCore > 1 + ? firstLogicalCore + ((thread + 1) % threadsPerCore) + : null, + LogicalProcessorName = $"CPU{physicalCore}_T{thread}", + }); + } + } + + return topology; + } + + private static string CreateTempMasksPath() + { + var directory = Path.Combine(Path.GetTempPath(), "ThreadPilot-CoreMaskServiceTests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + return Path.Combine(directory, "core_masks.json"); + } + + private static object CreateStoredMask( + string id, + string name, + IEnumerable boolMask, + bool isDefault = false) => + new + { + id, + name, + description = $"{name} description", + boolMask = boolMask.ToList(), + profileSchemaVersion = CpuAffinityProfileSchemaVersions.Legacy, + cpuSelection = (CpuSelection?)null, + cpuSelectionMigration = (CpuSelectionMigrationMetadata?)null, + isDefault, + isEnabled = true, + createdAt = DateTime.UtcNow.AddDays(-1), + updatedAt = DateTime.UtcNow.AddDays(-1), + }; + + private static Task WriteMasksAsync(string masksFilePath, params object[] masks) + { + var json = JsonSerializer.Serialize(masks, JsonOptions); + return File.WriteAllTextAsync(masksFilePath, json); + } + } +} diff --git a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs index 8cddc7e..494b9d2 100644 --- a/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ProcessViewXamlBindingTests.cs @@ -208,6 +208,27 @@ public void MasksView_SelectedCpuTilesUseSubtleMaskSelectionResources() Assert.DoesNotContain("SoftSelectionBackgroundBrush", serialized, StringComparison.Ordinal); } + [Fact] + public void MasksView_SelectedMaskListItemUsesReadableFluentSelection() + { + var masksViewPath = Path.Combine( + GetRepositoryRoot(), + "Views", + "MasksView.xaml"); + var document = XDocument.Load(masksViewPath, LoadOptions.PreserveWhitespace); + var serialized = document.ToString(SaveOptions.DisableFormatting); + + Assert.Contains("x:Key=\"MaskListItemStyle\"", serialized, StringComparison.Ordinal); + Assert.Contains("TargetType=\"{x:Type ListBoxItem}\"", serialized, StringComparison.Ordinal); + Assert.Contains("ItemContainerStyle=\"{StaticResource MaskListItemStyle}\"", serialized, StringComparison.Ordinal); + Assert.Contains("MaskSelectedListBackgroundBrush", serialized, StringComparison.Ordinal); + Assert.Contains("MaskSelectedBorderBrush", serialized, StringComparison.Ordinal); + Assert.Contains("BorderThickness\" Value=\"2\"", serialized, StringComparison.Ordinal); + Assert.Contains("Foreground\" Value=\"{DynamicResource TextFillColorPrimaryBrush}\"", serialized, StringComparison.Ordinal); + Assert.DoesNotContain("TextOnAccentFillColorPrimaryBrush", serialized, StringComparison.Ordinal); + Assert.DoesNotContain("AccentFillColorDefaultBrush", serialized, StringComparison.Ordinal); + } + [Fact] public void MainWindow_ContainsStartupMinimizedSuggestionOverlay() { @@ -221,6 +242,23 @@ public void MainWindow_ContainsStartupMinimizedSuggestionOverlay() Assert.Contains("Don't show again", serialized, StringComparison.Ordinal); } + [Fact] + public void MainWindow_QueuesStartupUpdateCheckOnceWithoutBlockingStartup() + { + var mainWindowBehaviorPath = Path.Combine(GetRepositoryRoot(), "MainWindow.Behaviors.partial.cs"); + var source = File.ReadAllText(mainWindowBehaviorPath); + var updateCheckSection = source[ + source.IndexOf("private void QueueStartupUpdateCheck()", StringComparison.Ordinal).. + source.IndexOf("private void UpdateLoadingStatus", StringComparison.Ordinal)]; + + Assert.Contains("QueueStartupUpdateCheck();", source, StringComparison.Ordinal); + Assert.Contains("Interlocked.Exchange(ref this.startupUpdateCheckStarted, 1)", updateCheckSection, StringComparison.Ordinal); + Assert.Contains("TaskSafety.FireAndForget(this.CheckForUpdatesAtStartupAsync()", updateCheckSection, StringComparison.Ordinal); + Assert.Contains("GetLatestVersionAsync(\"PrimeBuild-pc\", \"ThreadPilot\")", updateCheckSection, StringComparison.Ordinal); + Assert.Contains("Startup update check ignored failure", updateCheckSection, StringComparison.Ordinal); + Assert.DoesNotContain("System.Windows.MessageBox.Show", updateCheckSection, StringComparison.Ordinal); + } + [Fact] public void LegacyActionSidePanel_IsNotPersistentPrimaryUi() { diff --git a/Tests/ThreadPilot.Core.Tests/ThemeDictionaryPolicyTests.cs b/Tests/ThreadPilot.Core.Tests/ThemeDictionaryPolicyTests.cs index daf4785..8f4e384 100644 --- a/Tests/ThreadPilot.Core.Tests/ThemeDictionaryPolicyTests.cs +++ b/Tests/ThreadPilot.Core.Tests/ThemeDictionaryPolicyTests.cs @@ -85,9 +85,28 @@ public void ThemeDictionaries_DefineSharedVisualResourceKeys(string themePath) Assert.Contains("StatusPillBackgroundBrush", themeText, StringComparison.Ordinal); Assert.Contains("AppFontFamily", themeText, StringComparison.Ordinal); Assert.Contains("MaskSelectedBackgroundBrush", themeText, StringComparison.Ordinal); + Assert.Contains("MaskSelectedListBackgroundBrush", themeText, StringComparison.Ordinal); Assert.Contains("MaskSelectedBorderBrush", themeText, StringComparison.Ordinal); } + [Fact] + public void DarkTheme_MaskListSelectionUsesSubtleTintWithoutAccentForeground() + { + var themeText = File.ReadAllText(GetRepositoryFilePath("Themes/FluentDark.xaml")); + + Assert.Contains("x:Key=\"MaskSelectedListBackgroundBrush\"", themeText, StringComparison.Ordinal); + Assert.Contains("Opacity=\"0.05\"", themeText, StringComparison.Ordinal); + Assert.Contains("x:Key=\"MaskSelectedBorderBrush\"", themeText, StringComparison.Ordinal); + Assert.DoesNotContain( + "x:Key=\"MaskSelectedListBackgroundBrush\" Color=\"{StaticResource AccentFillColorDefault}\"", + themeText, + StringComparison.Ordinal); + Assert.DoesNotContain( + "x:Key=\"MaskSelectedListForegroundBrush\" Color=\"{StaticResource TextOnAccentFillColorPrimary}\"", + themeText, + StringComparison.Ordinal); + } + private static ResourceDictionary CreateDictionaryWithSource(Uri source) { var dictionary = new ResourceDictionary(); diff --git a/Themes/FluentDark.xaml b/Themes/FluentDark.xaml index 4e432b4..3f68deb 100644 --- a/Themes/FluentDark.xaml +++ b/Themes/FluentDark.xaml @@ -96,6 +96,7 @@ + diff --git a/Themes/FluentLight.xaml b/Themes/FluentLight.xaml index c7bd8c6..ef9be80 100644 --- a/Themes/FluentLight.xaml +++ b/Themes/FluentLight.xaml @@ -96,6 +96,7 @@ + diff --git a/Views/MasksView.xaml b/Views/MasksView.xaml index ba4c4be..54adb40 100644 --- a/Views/MasksView.xaml +++ b/Views/MasksView.xaml @@ -6,6 +6,47 @@ mc:Ignorable="d" d:DesignHeight="600" d:DesignWidth="900"> + + + + @@ -42,6 +83,7 @@ CornerRadius="{DynamicResource StandardCardCornerRadius}">