Skip to content

Commit b45fcf1

Browse files
Add Process tab context menu actions (#19)
Add Process tab context menu actions Add a lightweight right-click context menu to Process tab rows using existing safe process action paths. - Add row context menu for Process tab DataGrid rows - Add row-specific Apply Affinity command using ProcessAffinityApplyCoordinator - Add Clear CPU Sets action using the safe CPU Sets clear path - Add CPU priority actions while keeping Realtime blocked - Add memory priority actions through ProcessMemoryPriorityService - Add Open Executable Location, Copy Process Info and Refresh Process Info actions - Refresh selected-process summary after context actions - Keep context menu actions from creating persistent rules - Add tests for row-specific affinity, CPU priority, memory priority, copy/open/refresh, CPU Sets clear and no rule creation No save-as-rule flow, rules editing UI, persistent rule creation, affinity core changes, memory-priority backend changes, diagnostics changes, version bump or tag changes.
1 parent 6eec1ba commit b45fcf1

5 files changed

Lines changed: 822 additions & 8 deletions

File tree

Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
1+
namespace ThreadPilot.Core.Tests
2+
{
3+
using System.Collections.ObjectModel;
4+
using System.Diagnostics;
5+
using Microsoft.Extensions.Logging.Abstractions;
6+
using Moq;
7+
using ThreadPilot.Models;
8+
using ThreadPilot.Services;
9+
using ThreadPilot.ViewModels;
10+
11+
public sealed class ProcessViewModelContextMenuTests
12+
{
13+
[Fact]
14+
public async Task ContextCpuPriorityCommand_CallsSafePriorityServicePath()
15+
{
16+
var processService = CreateProcessService();
17+
var viewModel = CreateViewModel(processService.Object);
18+
var process = CreateProcess(priority: ProcessPriorityClass.Normal);
19+
20+
await viewModel.SetContextHighPriorityCommand.ExecuteAsync(process);
21+
22+
processService.Verify(
23+
service => service.SetProcessPriority(process, ProcessPriorityClass.High),
24+
Times.Once);
25+
Assert.Equal(ProcessOperationUserMessages.HighPriorityWarning, viewModel.StatusMessage);
26+
Assert.False(viewModel.HasError);
27+
}
28+
29+
[Fact]
30+
public async Task ApplyContextAffinityCommand_UsesProvidedRowProcess()
31+
{
32+
var processService = CreateProcessService();
33+
var coordinator = CreateAffinityCoordinator();
34+
var viewModel = CreateViewModel(
35+
processService.Object,
36+
processAffinityApplyCoordinator: coordinator.Object);
37+
viewModel.CpuCores =
38+
[
39+
new CpuCoreModel { LogicalCoreId = 0, IsSelected = true },
40+
new CpuCoreModel { LogicalCoreId = 1, IsSelected = false },
41+
];
42+
var rowProcess = CreateProcess(processId: 100);
43+
44+
await viewModel.ApplyContextAffinityCommand.ExecuteAsync(rowProcess);
45+
46+
coordinator.Verify(
47+
service => service.ApplyCoreSelectionAsync(
48+
rowProcess,
49+
It.Is<IReadOnlyList<bool>>(mask => mask.Count == 2 && mask[0] && !mask[1]),
50+
"Manual Process tab context menu CPU selection",
51+
default),
52+
Times.Once);
53+
Assert.Same(rowProcess, viewModel.SelectedProcess);
54+
}
55+
56+
[Fact]
57+
public async Task ApplyContextAffinityCommand_WhenRowProcessDiffersFromSelectedProcess_UsesRowProcess()
58+
{
59+
var processService = CreateProcessService();
60+
var coordinator = CreateAffinityCoordinator();
61+
var viewModel = CreateViewModel(
62+
processService.Object,
63+
processAffinityApplyCoordinator: coordinator.Object);
64+
viewModel.CpuCores =
65+
[
66+
new CpuCoreModel { LogicalCoreId = 0, IsSelected = true },
67+
new CpuCoreModel { LogicalCoreId = 1, IsSelected = true },
68+
];
69+
var oldSelectedProcess = CreateProcess(processId: 1, name: "Old.exe");
70+
var rowProcess = CreateProcess(processId: 2, name: "Row.exe");
71+
viewModel.SelectedProcess = oldSelectedProcess;
72+
73+
await viewModel.ApplyContextAffinityCommand.ExecuteAsync(rowProcess);
74+
75+
coordinator.Verify(
76+
service => service.ApplyCoreSelectionAsync(
77+
rowProcess,
78+
It.IsAny<IReadOnlyList<bool>>(),
79+
"Manual Process tab context menu CPU selection",
80+
default),
81+
Times.Once);
82+
coordinator.Verify(
83+
service => service.ApplyCoreSelectionAsync(
84+
oldSelectedProcess,
85+
It.IsAny<IReadOnlyList<bool>>(),
86+
It.IsAny<string>(),
87+
default),
88+
Times.Never);
89+
Assert.Same(rowProcess, viewModel.SelectedProcess);
90+
}
91+
92+
[Fact]
93+
public async Task ApplyContextAffinityCommand_DoesNotCallLegacyLongDirectly()
94+
{
95+
var processService = CreateProcessService();
96+
var coordinator = CreateAffinityCoordinator();
97+
var viewModel = CreateViewModel(
98+
processService.Object,
99+
processAffinityApplyCoordinator: coordinator.Object);
100+
viewModel.CpuCores =
101+
[
102+
new CpuCoreModel { LogicalCoreId = 0, IsSelected = true },
103+
];
104+
var rowProcess = CreateProcess();
105+
106+
await viewModel.ApplyContextAffinityCommand.ExecuteAsync(rowProcess);
107+
108+
coordinator.Verify(
109+
service => service.ApplyCoreSelectionAsync(
110+
rowProcess,
111+
It.IsAny<IReadOnlyList<bool>>(),
112+
"Manual Process tab context menu CPU selection",
113+
default),
114+
Times.Once);
115+
processService.Verify(
116+
service => service.SetProcessorAffinity(It.IsAny<ProcessModel>(), It.IsAny<long>()),
117+
Times.Never);
118+
}
119+
120+
[Fact]
121+
public void ContextCpuPriorityActions_DoNotExposeRealtimeAsNormalAction()
122+
{
123+
var viewModel = CreateViewModel(CreateProcessService().Object);
124+
125+
Assert.DoesNotContain(ProcessPriorityClass.RealTime, viewModel.ContextMenuCpuPriorityActions);
126+
Assert.Contains(ProcessPriorityClass.High, viewModel.ContextMenuCpuPriorityActions);
127+
}
128+
129+
[Fact]
130+
public async Task ContextMemoryPriorityCommand_CallsMemoryPriorityService()
131+
{
132+
var memoryPriorityService = new Mock<IProcessMemoryPriorityService>(MockBehavior.Strict);
133+
memoryPriorityService
134+
.Setup(service => service.SetMemoryPriorityAsync(It.IsAny<ProcessModel>(), ProcessMemoryPriority.Low))
135+
.ReturnsAsync(ProcessOperationResult.Succeeded("Memory priority applied.", "ok"));
136+
memoryPriorityService
137+
.Setup(service => service.GetMemoryPriorityAsync(It.IsAny<ProcessModel>()))
138+
.ReturnsAsync(ProcessMemoryPriority.Low);
139+
var process = CreateProcess();
140+
var viewModel = CreateViewModel(
141+
CreateProcessService().Object,
142+
memoryPriorityService: memoryPriorityService.Object);
143+
144+
await viewModel.SetContextMemoryPriorityLowCommand.ExecuteAsync(process);
145+
146+
memoryPriorityService.Verify(
147+
service => service.SetMemoryPriorityAsync(process, ProcessMemoryPriority.Low),
148+
Times.Once);
149+
}
150+
151+
[Fact]
152+
public async Task ContextMemoryPriorityCommand_WhenServiceFails_ShowsSafeUserMessage()
153+
{
154+
var memoryPriorityService = new Mock<IProcessMemoryPriorityService>(MockBehavior.Strict);
155+
memoryPriorityService
156+
.Setup(service => service.SetMemoryPriorityAsync(It.IsAny<ProcessModel>(), ProcessMemoryPriority.Normal))
157+
.ReturnsAsync(ProcessOperationResult.Failed(
158+
"AccessDenied",
159+
ProcessOperationUserMessages.AccessDenied,
160+
"Access is denied.",
161+
isAccessDenied: true));
162+
var process = CreateProcess();
163+
var viewModel = CreateViewModel(
164+
CreateProcessService().Object,
165+
memoryPriorityService: memoryPriorityService.Object);
166+
167+
await viewModel.SetContextMemoryPriorityNormalCommand.ExecuteAsync(process);
168+
169+
Assert.Equal(ProcessOperationUserMessages.AccessDenied, viewModel.StatusMessage);
170+
Assert.True(viewModel.HasError);
171+
}
172+
173+
[Fact]
174+
public async Task ContextMemoryPriorityCommand_WhenSuccessful_UpdatesSelectedProcessSummary()
175+
{
176+
var memoryPriorityService = new Mock<IProcessMemoryPriorityService>(MockBehavior.Strict);
177+
memoryPriorityService
178+
.Setup(service => service.SetMemoryPriorityAsync(It.IsAny<ProcessModel>(), ProcessMemoryPriority.BelowNormal))
179+
.ReturnsAsync(ProcessOperationResult.Succeeded("Memory priority applied.", "ok"));
180+
memoryPriorityService
181+
.Setup(service => service.GetMemoryPriorityAsync(It.IsAny<ProcessModel>()))
182+
.ReturnsAsync(ProcessMemoryPriority.BelowNormal);
183+
var process = CreateProcess();
184+
var viewModel = CreateViewModel(
185+
CreateProcessService().Object,
186+
memoryPriorityService: memoryPriorityService.Object);
187+
188+
await viewModel.SetContextMemoryPriorityBelowNormalCommand.ExecuteAsync(process);
189+
190+
Assert.Equal(ProcessMemoryPriority.BelowNormal, viewModel.SelectedProcessSummary.MemoryPriority);
191+
Assert.Equal("Memory priority: BelowNormal", viewModel.SelectedProcessSummary.MemoryPriorityText);
192+
}
193+
194+
[Fact]
195+
public async Task CopyContextProcessInfo_IncludesNamePidAndPath()
196+
{
197+
string? copiedText = null;
198+
var process = CreateProcess();
199+
var viewModel = CreateViewModel(
200+
CreateProcessService().Object,
201+
clipboardSetter: text => copiedText = text);
202+
203+
await viewModel.CopyContextProcessInfoCommand.ExecuteAsync(process);
204+
205+
Assert.NotNull(copiedText);
206+
Assert.Contains("Name: Game.exe", copiedText);
207+
Assert.Contains("PID: 42", copiedText);
208+
Assert.Contains(@"Path: C:\Games\Game.exe", copiedText);
209+
}
210+
211+
[Fact]
212+
public async Task CopyContextProcessInfo_WhenPathMissing_DoesNotThrow()
213+
{
214+
string? copiedText = null;
215+
var process = CreateProcess(path: string.Empty);
216+
var viewModel = CreateViewModel(
217+
CreateProcessService().Object,
218+
clipboardSetter: text => copiedText = text);
219+
220+
var exception = await Record.ExceptionAsync(
221+
() => viewModel.CopyContextProcessInfoCommand.ExecuteAsync(process));
222+
223+
Assert.Null(exception);
224+
Assert.Contains("Path: unavailable", copiedText);
225+
}
226+
227+
[Fact]
228+
public async Task OpenContextExecutableLocation_WhenPathMissing_DoesNotThrow()
229+
{
230+
var viewModel = CreateViewModel(CreateProcessService().Object);
231+
232+
var exception = await Record.ExceptionAsync(
233+
() => viewModel.OpenContextExecutableLocationCommand.ExecuteAsync(CreateProcess(path: string.Empty)));
234+
235+
Assert.Null(exception);
236+
Assert.Equal("Executable path is unavailable for Game.exe.", viewModel.StatusMessage);
237+
Assert.True(viewModel.HasError);
238+
}
239+
240+
[Fact]
241+
public async Task ClearContextCpuSetsCommand_CallsSafeCpuSetClearPath()
242+
{
243+
var processService = CreateProcessService();
244+
processService
245+
.Setup(service => service.ClearProcessCpuSetAsync(It.IsAny<ProcessModel>()))
246+
.ReturnsAsync(true);
247+
var process = CreateProcess();
248+
var viewModel = CreateViewModel(processService.Object);
249+
250+
await viewModel.ClearContextCpuSetsCommand.ExecuteAsync(process);
251+
252+
processService.Verify(service => service.ClearProcessCpuSetAsync(process), Times.Once);
253+
}
254+
255+
[Fact]
256+
public async Task RefreshContextProcessInfoCommand_RefreshesSelectedProcessInfo()
257+
{
258+
var processService = CreateProcessService();
259+
var process = CreateProcess();
260+
var viewModel = CreateViewModel(processService.Object);
261+
262+
await viewModel.RefreshContextProcessInfoCommand.ExecuteAsync(process);
263+
264+
processService.Verify(service => service.RefreshProcessInfo(process), Times.Once);
265+
Assert.Equal("Process info refreshed for Game.exe.", viewModel.StatusMessage);
266+
}
267+
268+
[Fact]
269+
public async Task ContextMenuActions_DoNotCreatePersistentRules()
270+
{
271+
var processService = CreateProcessService();
272+
var memoryPriorityService = new Mock<IProcessMemoryPriorityService>(MockBehavior.Strict);
273+
memoryPriorityService
274+
.Setup(service => service.SetMemoryPriorityAsync(It.IsAny<ProcessModel>(), ProcessMemoryPriority.VeryLow))
275+
.ReturnsAsync(ProcessOperationResult.Succeeded("Memory priority applied.", "ok"));
276+
memoryPriorityService
277+
.Setup(service => service.GetMemoryPriorityAsync(It.IsAny<ProcessModel>()))
278+
.ReturnsAsync(ProcessMemoryPriority.VeryLow);
279+
var ruleStore = new Mock<IPersistentProcessRuleStore>(MockBehavior.Strict);
280+
ruleStore
281+
.Setup(store => store.LoadAsync())
282+
.ReturnsAsync(Array.Empty<PersistentProcessRule>());
283+
var viewModel = CreateViewModel(
284+
processService.Object,
285+
memoryPriorityService: memoryPriorityService.Object,
286+
persistentRuleStore: ruleStore.Object,
287+
clipboardSetter: _ => { });
288+
var process = CreateProcess();
289+
290+
await viewModel.SetContextAboveNormalPriorityCommand.ExecuteAsync(process);
291+
await viewModel.SetContextMemoryPriorityVeryLowCommand.ExecuteAsync(process);
292+
await viewModel.CopyContextProcessInfoCommand.ExecuteAsync(process);
293+
294+
ruleStore.Verify(store => store.SaveAsync(It.IsAny<IReadOnlyList<PersistentProcessRule>>()), Times.Never);
295+
}
296+
297+
private static Mock<IProcessService> CreateProcessService()
298+
{
299+
var processService = new Mock<IProcessService>(MockBehavior.Loose);
300+
processService
301+
.Setup(service => service.GetProcessesAsync())
302+
.ReturnsAsync(new ObservableCollection<ProcessModel>());
303+
processService
304+
.Setup(service => service.GetActiveApplicationsAsync())
305+
.ReturnsAsync(new ObservableCollection<ProcessModel>());
306+
processService
307+
.Setup(service => service.IsProcessStillRunning(It.IsAny<ProcessModel>()))
308+
.ReturnsAsync(true);
309+
processService
310+
.Setup(service => service.RefreshProcessInfo(It.IsAny<ProcessModel>()))
311+
.Returns(Task.CompletedTask);
312+
return processService;
313+
}
314+
315+
private static Mock<IProcessAffinityApplyCoordinator> CreateAffinityCoordinator()
316+
{
317+
var coordinator = new Mock<IProcessAffinityApplyCoordinator>(MockBehavior.Strict);
318+
coordinator
319+
.Setup(service => service.ApplyCoreSelectionAsync(
320+
It.IsAny<ProcessModel>(),
321+
It.IsAny<IReadOnlyList<bool>>(),
322+
It.IsAny<string>(),
323+
default))
324+
.ReturnsAsync(AffinityApplyResult.Succeeded(1, 1));
325+
return coordinator;
326+
}
327+
328+
private static ProcessViewModel CreateViewModel(
329+
IProcessService processService,
330+
IProcessAffinityApplyCoordinator? processAffinityApplyCoordinator = null,
331+
IProcessMemoryPriorityService? memoryPriorityService = null,
332+
IPersistentProcessRuleStore? persistentRuleStore = null,
333+
Action<string>? clipboardSetter = null,
334+
Action<string>? executableLocationOpener = null)
335+
{
336+
var virtualizedProcessService = new Mock<IVirtualizedProcessService>(MockBehavior.Loose);
337+
virtualizedProcessService.SetupProperty(
338+
service => service.Configuration,
339+
new VirtualizedProcessConfig());
340+
341+
var cpuTopologyService = new Mock<ICpuTopologyService>(MockBehavior.Loose);
342+
var powerPlanService = new Mock<IPowerPlanService>(MockBehavior.Loose);
343+
var notificationService = new Mock<INotificationService>(MockBehavior.Loose);
344+
var systemTrayService = new Mock<ISystemTrayService>(MockBehavior.Loose);
345+
var coreMaskService = new Mock<ICoreMaskService>(MockBehavior.Loose);
346+
var associationService = new Mock<IProcessPowerPlanAssociationService>(MockBehavior.Loose);
347+
var gameModeService = new Mock<IGameModeService>(MockBehavior.Loose);
348+
349+
return new ProcessViewModel(
350+
NullLogger<ProcessViewModel>.Instance,
351+
processService,
352+
new ProcessFilterService(),
353+
virtualizedProcessService.Object,
354+
cpuTopologyService.Object,
355+
powerPlanService.Object,
356+
notificationService.Object,
357+
systemTrayService.Object,
358+
coreMaskService.Object,
359+
associationService.Object,
360+
gameModeService.Object,
361+
processAffinityApplyCoordinator: processAffinityApplyCoordinator,
362+
memoryPriorityService: memoryPriorityService,
363+
persistentRuleStore: persistentRuleStore,
364+
persistentRuleMatcher: new PersistentProcessRuleMatcher(),
365+
clipboardSetter: clipboardSetter,
366+
executableLocationOpener: executableLocationOpener);
367+
}
368+
369+
private static ProcessModel CreateProcess(
370+
string name = "Game.exe",
371+
int processId = 42,
372+
string path = @"C:\Games\Game.exe",
373+
ProcessPriorityClass priority = ProcessPriorityClass.Normal)
374+
=> new()
375+
{
376+
ProcessId = processId,
377+
Name = name,
378+
ExecutablePath = path,
379+
CpuUsage = 1.5,
380+
MemoryUsage = 128 * 1024 * 1024,
381+
Priority = priority,
382+
ProcessorAffinity = 0xF,
383+
Classification = ProcessClassification.ForegroundApp,
384+
};
385+
}
386+
}

0 commit comments

Comments
 (0)