Skip to content

Commit e113b8c

Browse files
Add unit tests for Control.InvokeAsync.
1 parent ac95370 commit e113b8c

File tree

3 files changed

+319
-3
lines changed

3 files changed

+319
-3
lines changed
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
# InvokeAsync Unit Test Instructions for Copilot
2+
3+
## Method Signatures to Test
4+
5+
```csharp
6+
// Async callback returning ValueTask
7+
public async Task InvokeAsync(Func<CancellationToken, ValueTask> callback, CancellationToken cancellationToken = default)
8+
9+
// Async callback returning ValueTask<T>
10+
public async Task<T> InvokeAsync<T>(Func<CancellationToken, ValueTask<T>> callback, CancellationToken cancellationToken = default)
11+
12+
// Sync callback returning T
13+
public async Task<T> InvokeAsync<T>(Func<T> callback, CancellationToken cancellationToken = default)
14+
15+
// Sync callback returning void
16+
public async Task InvokeAsync(Action callback, CancellationToken cancellationToken = default)
17+
```
18+
19+
## Required Test Coverage
20+
21+
Please create comprehensive unit tests for each overload that verify:
22+
23+
### Core Functionality
24+
- **UI Thread Delegation**: Verify the callback executes on the UI thread (different from calling thread)
25+
- **Cancellation Support**: Test cancellation works even when callback doesn't support it (sync overloads)
26+
- **Async Cancellation**: Test cancellation works when callback supports it (async overloads with CancellationToken)
27+
- **Exception Propagation**: Verify exceptions from callbacks are properly propagated to caller
28+
29+
### Edge Cases
30+
- **Handle Not Created**: Verify `InvalidOperationException` when control handle isn't created
31+
- **Pre-cancelled Token**: Verify early return when token is already cancelled
32+
- **Multiple Concurrent Calls**: Test thread safety with overlapping invocations
33+
- **Reentry Scenarios**: Test calling InvokeAsync from within a callback
34+
35+
### Cancellation Scenarios
36+
- **External Cancellation**: Cancel token while callback is queued/running
37+
- **Callback Cancellation**: For async overloads, test cancellation within the callback itself
38+
- **Registration Cleanup**: Verify cancellation registrations are properly disposed
39+
40+
### Return Value Testing
41+
- **Generic Overloads**: Test proper return value handling for `Task<T>` variants
42+
- **Void Overload**: Test completion signaling for `Action` overload
43+
44+
### Performance/Resource Testing
45+
- **Memory Leaks**: Verify no leaked registrations or task completion sources
46+
- **Async Context**: Verify ConfigureAwait behavior and sync context handling
47+
48+
## Test Structure Guidance
49+
50+
- Use a test control with proper handle creation for UI thread tests
51+
- Use `Thread.CurrentThread.ManagedThreadId` to verify thread marshalling
52+
- Use `CancellationTokenSource` with timeouts for cancellation tests
53+
- Include both immediate and delayed cancellation scenarios
54+
- Test with both short-running and long-running callbacks
55+
- Use appropriate async test patterns with proper awaiting
56+
57+
Create tests that are robust, deterministic, and cover both happy path and error conditions.

src/System.Windows.Forms/GlobalSuppressions.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33

44
// Publicly shipped API
55

6-
76
[assembly: SuppressMessage("Naming", "CA1725:Parameter names should match base declaration", Justification = "Public API", Scope = "member", Target = "~M:System.Windows.Forms.ButtonBase.OnKeyDown(System.Windows.Forms.KeyEventArgs)")]
87
[assembly: SuppressMessage("Naming", "CA1725:Parameter names should match base declaration", Justification = "Public API", Scope = "member", Target = "~M:System.Windows.Forms.ButtonBase.OnKeyUp(System.Windows.Forms.KeyEventArgs)")]
98
[assembly: SuppressMessage("Naming", "CA1725:Parameter names should match base declaration", Justification = "Public API", Scope = "member", Target = "~M:System.Windows.Forms.ButtonBase.OnMouseDown(System.Windows.Forms.MouseEventArgs)")]
@@ -270,5 +269,5 @@
270269
[assembly: SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Analyzer wrongly complains for new APIs - known issue.", Scope = "member", Target = "~M:System.Windows.Forms.TaskDialog.ShowDialogAsync(System.Windows.Forms.TaskDialogPage,System.Windows.Forms.TaskDialogStartupLocation)~System.Threading.Tasks.Task{System.Windows.Forms.TaskDialogButton}")]
271270
[assembly: SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Analyzer wrongly complains for new APIs - known issue.", Scope = "member", Target = "~M:System.Windows.Forms.TaskDialog.ShowDialogAsync(System.Windows.Forms.IWin32Window,System.Windows.Forms.TaskDialogPage,System.Windows.Forms.TaskDialogStartupLocation)~System.Threading.Tasks.Task{System.Windows.Forms.TaskDialogButton}")]
272271
[assembly: SuppressMessage("ApiDesign", "RS0026:Do not add multiple public overloads with optional parameters", Justification = "Analyzer wrongly complains for new APIs - known issue.", Scope = "member", Target = "~M:System.Windows.Forms.TaskDialog.ShowDialogAsync(System.IntPtr,System.Windows.Forms.TaskDialogPage,System.Windows.Forms.TaskDialogStartupLocation)~System.Threading.Tasks.Task{System.Windows.Forms.TaskDialogButton}")]
273-
[assembly: SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = "<Pending>", Scope = "member", Target = "~M:System.Windows.Forms.Control.InvokeAsync(System.Func{System.Threading.CancellationToken,System.Threading.Tasks.ValueTask},System.Threading.CancellationToken)~System.Threading.Tasks.Task")]
274-
[assembly: SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = "<Pending>", Scope = "member", Target = "~M:System.Windows.Forms.Control.InvokeAsync``1(System.Func{System.Threading.CancellationToken,System.Threading.Tasks.ValueTask{``0}},System.Threading.CancellationToken)~System.Threading.Tasks.Task{``0}")]
272+
[assembly: SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = "ConfigureAwait does not apply in this context.", Scope = "member", Target = "~M:System.Windows.Forms.Control.InvokeAsync(System.Func{System.Threading.CancellationToken,System.Threading.Tasks.ValueTask},System.Threading.CancellationToken)~System.Threading.Tasks.Task")]
273+
[assembly: SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = "ConfigureAwait does not apply in this context.", Scope = "member", Target = "~M:System.Windows.Forms.Control.InvokeAsync``1(System.Func{System.Threading.CancellationToken,System.Threading.Tasks.ValueTask{``0}},System.Threading.CancellationToken)~System.Threading.Tasks.Task{``0}")]
Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace System.Windows.Forms.Tests;
5+
6+
public partial class ControlTests
7+
{
8+
private sealed class TestControl : Control
9+
{
10+
public void EnsureHandle() => _ = Handle;
11+
public void DestroyTestHandle() => DestroyHandle();
12+
}
13+
14+
[Fact]
15+
public async Task InvokeAsync_Action_ExecutesOnUIThread()
16+
{
17+
using var control = new TestControl();
18+
control.EnsureHandle();
19+
20+
int originalThread = Environment.CurrentManagedThreadId;
21+
int? callbackThread = null;
22+
23+
await control.InvokeAsync(
24+
() => callbackThread = Environment.CurrentManagedThreadId);
25+
26+
Assert.NotNull(callbackThread);
27+
Assert.NotEqual(originalThread, callbackThread.Value);
28+
Assert.Equal(control.InvokeRequired
29+
? callbackThread.Value
30+
: originalThread, control.InvokeRequired
31+
? callbackThread.Value
32+
: originalThread);
33+
}
34+
35+
[Fact]
36+
public async Task InvokeAsync_FuncT_ExecutesOnUIThread_AndReturnsValue()
37+
{
38+
using var control = new TestControl();
39+
control.EnsureHandle();
40+
int originalThread = Environment.CurrentManagedThreadId;
41+
42+
int? callbackThread = null;
43+
44+
int result = await control.InvokeAsync(() =>
45+
{
46+
callbackThread = Environment.CurrentManagedThreadId;
47+
return 42;
48+
});
49+
50+
Assert.NotNull(callbackThread);
51+
Assert.NotEqual(originalThread, callbackThread.Value);
52+
Assert.Equal(42, result);
53+
}
54+
55+
[Fact]
56+
public async Task InvokeAsync_AsyncCallback_ExecutesOnUIThread()
57+
{
58+
using var control = new TestControl();
59+
control.EnsureHandle();
60+
61+
int originalThread = Environment.CurrentManagedThreadId;
62+
int? callbackThread = null;
63+
64+
await control.InvokeAsync(async ct =>
65+
{
66+
await Task.Delay(10, ct).ConfigureAwait(false);
67+
callbackThread = Environment.CurrentManagedThreadId;
68+
69+
return;
70+
});
71+
72+
Assert.NotNull(callbackThread);
73+
Assert.NotEqual(originalThread, callbackThread.Value);
74+
}
75+
76+
[Fact]
77+
public async Task InvokeAsync_AsyncCallbackT_ExecutesOnUIThread_AndReturnsValue()
78+
{
79+
using var control = new TestControl();
80+
control.EnsureHandle();
81+
int originalThread = Environment.CurrentManagedThreadId;
82+
int? callbackThread = null;
83+
84+
int result = await control.InvokeAsync(
85+
async ct =>
86+
{
87+
await Task.Delay(10, ct).ConfigureAwait(false);
88+
callbackThread = Environment.CurrentManagedThreadId;
89+
return 99;
90+
});
91+
92+
Assert.NotNull(callbackThread);
93+
Assert.NotEqual(originalThread, callbackThread.Value);
94+
Assert.Equal(99, result);
95+
}
96+
97+
[Fact]
98+
public async Task InvokeAsync_Action_Cancellation_PreCancelledToken_ReturnsEarly()
99+
{
100+
using var control = new TestControl();
101+
control.EnsureHandle();
102+
103+
using var cts = new CancellationTokenSource();
104+
await cts.CancelAsync();
105+
106+
await control.InvokeAsync(
107+
() => throw new ArgumentOutOfRangeException("Should not run"), cts.Token);
108+
}
109+
110+
[Fact]
111+
public async Task InvokeAsync_FuncT_Cancellation_PreCancelledToken_ReturnsDefault()
112+
{
113+
using var control = new TestControl();
114+
control.EnsureHandle();
115+
116+
using var cts = new CancellationTokenSource();
117+
await cts.CancelAsync();
118+
119+
int result = await control.InvokeAsync(
120+
CallBack,
121+
cts.Token);
122+
123+
Assert.Equal(0, result);
124+
125+
static ValueTask<int> CallBack(CancellationToken ct)
126+
{
127+
throw new ArgumentOutOfRangeException("Should not run");
128+
}
129+
}
130+
131+
[Fact]
132+
public async Task InvokeAsync_AsyncCallback_Cancellation_PreCancelledToken_ReturnsEarly()
133+
{
134+
using var control = new TestControl();
135+
control.EnsureHandle();
136+
137+
using var cts = new CancellationTokenSource();
138+
await cts.CancelAsync();
139+
140+
await control.InvokeAsync(
141+
ct => throw new ArgumentOutOfRangeException("Should not run"),
142+
cts.Token);
143+
}
144+
145+
[Fact]
146+
public async Task InvokeAsync_AsyncCallbackT_Cancellation_PreCancelledToken_ReturnsDefault()
147+
{
148+
using var control = new TestControl();
149+
control.EnsureHandle();
150+
using var cts = new CancellationTokenSource();
151+
await cts.CancelAsync();
152+
153+
int result = await control.InvokeAsync<int>(
154+
ct => throw new ArgumentOutOfRangeException("Should not run"),
155+
cts.Token);
156+
157+
Assert.Equal(0, result);
158+
}
159+
160+
[Fact]
161+
public async Task InvokeAsync_Action_Cancellation_WhileQueued()
162+
{
163+
using var control = new TestControl();
164+
control.EnsureHandle();
165+
using var cts = new CancellationTokenSource();
166+
167+
Task task = control.InvokeAsync(() =>
168+
{
169+
Thread.Sleep(50);
170+
}, cts.Token);
171+
172+
await cts.CancelAsync();
173+
await Assert.ThrowsAnyAsync<OperationCanceledException>(
174+
async () => await task.ConfigureAwait(false));
175+
}
176+
177+
[Fact]
178+
public async Task InvokeAsync_AsyncCallback_Cancellation_WhileRunning()
179+
{
180+
using var control = new TestControl();
181+
control.EnsureHandle();
182+
using var cts = new CancellationTokenSource();
183+
184+
Task task = control.InvokeAsync(async ct =>
185+
{
186+
await cts.CancelAsync().ConfigureAwait(false);
187+
ct.ThrowIfCancellationRequested();
188+
await Task.Delay(10, ct).ConfigureAwait(false);
189+
}, cts.Token);
190+
191+
await Assert.ThrowsAnyAsync<OperationCanceledException>(
192+
async () => await task.ConfigureAwait(false));
193+
}
194+
195+
[Fact]
196+
public async Task InvokeAsync_Throws_InvalidOperationException_IfHandleNotCreated()
197+
{
198+
using var control = new TestControl();
199+
200+
await Assert.ThrowsAsync<InvalidOperationException>(() =>
201+
control.InvokeAsync(ct => default, CancellationToken.None));
202+
203+
await Assert.ThrowsAsync<InvalidOperationException>(() =>
204+
control.InvokeAsync(
205+
ct => new ValueTask<int>(1),
206+
CancellationToken.None));
207+
}
208+
209+
[Fact]
210+
public async Task InvokeAsync_Propagates_Exception_FromCallback()
211+
{
212+
using var control = new TestControl();
213+
control.EnsureHandle();
214+
215+
await Assert.ThrowsAsync<InvalidOperationException>(() =>
216+
control.InvokeAsync(() => throw new InvalidOperationException()));
217+
218+
await Assert.ThrowsAsync<InvalidOperationException>(() =>
219+
control.InvokeAsync<int>(() => throw new InvalidOperationException()));
220+
221+
await Assert.ThrowsAsync<InvalidOperationException>(() =>
222+
control.InvokeAsync(ct => throw new InvalidOperationException()));
223+
224+
await Assert.ThrowsAsync<InvalidOperationException>(() =>
225+
control.InvokeAsync<int>(ct => throw new InvalidOperationException()));
226+
}
227+
228+
[Fact]
229+
public async Task InvokeAsync_Reentry_Supported()
230+
{
231+
using var control = new TestControl();
232+
control.EnsureHandle();
233+
bool innerCalled = false;
234+
235+
await control.InvokeAsync(async ct =>
236+
{
237+
await control.InvokeAsync(() => innerCalled = true, ct).ConfigureAwait(false);
238+
});
239+
240+
Assert.True(innerCalled);
241+
}
242+
243+
[Fact]
244+
public async Task InvokeAsync_MultipleConcurrentCalls_AreThreadSafe()
245+
{
246+
using var control = new TestControl();
247+
control.EnsureHandle();
248+
249+
int counter = 0;
250+
var tasks = new Task[10];
251+
252+
for (int i = 0; i < tasks.Length; i++)
253+
{
254+
tasks[i] = control.InvokeAsync(() => Interlocked.Increment(ref counter));
255+
}
256+
257+
await Task.WhenAll(tasks);
258+
Assert.Equal(10, counter);
259+
}
260+
}

0 commit comments

Comments
 (0)