Skip to content

Commit 3b4a82d

Browse files
authored
feat: add timer executed expectation (#148)
This pull request introduces a new feature for asserting timer executions in tests using the `ITimerMock` interface. It adds the ability to verify how many times a timer callback has been executed within a specified timeout, making asynchronous timer assertions straightforward and expressive. The changes include the implementation of the core logic, result type, extension methods, documentation, and comprehensive tests. The most important changes are: **New Timer Assertion Feature:** * Added the `TimerExtensions.Executed()` extension method for `ITimerMock`, allowing assertions on timer callback executions with quantifiers and timeouts. * Implemented the core polling logic in `TimerConstraints.TimerExecutedConstraint`, which checks the execution count until the quantifier is satisfied or the timeout expires. * Introduced the `TimerExecutedResult` type, providing a fluent API for specifying the timeout window with `.Within(timeout)`. **Documentation and API Surface:** * Updated the `README.md` with usage examples for the new timer assertions, explaining how to use quantifiers and timeouts in tests. * Updated public API files to reflect the new `TimerExtensions` and `TimerExecutedResult` types and methods. **Testing:** * Added comprehensive tests in `Timer.Executed.Tests.cs` to verify correct behavior for various timer execution scenarios, including success, failure, live updates, and null subjects.
1 parent 9c3256c commit 3b4a82d

8 files changed

Lines changed: 487 additions & 0 deletions

File tree

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,3 +230,27 @@ await That(change).HasNotifyFilters(NotifyFilters.LastWrite);
230230
await That(change).HasName("my-file.txt").And.HasPath("/abs/my-file.txt");
231231
await That(renamedChange).HasOldName("old.txt").And.HasOldPath("/abs/old.txt");
232232
```
233+
234+
## Time system
235+
236+
### Timers
237+
238+
A `MockTimeSystem` exposes timers as `ITimerMock`. You can assert how often the
239+
timer callback was executed without blocking the test thread:
240+
241+
```csharp
242+
MockTimeSystem timeSystem = new();
243+
ITimerMock timer = (ITimerMock)timeSystem.Timer.New(
244+
_ => { }, null, TimeSpan.Zero, TimeSpan.FromMilliseconds(10));
245+
246+
await That(timer).Executed(3.Times()).Within(5.Seconds());
247+
```
248+
249+
`Executed()` accepts a `Quantifier` (`AtLeast`, `AtMost`, `Exactly`,
250+
`Between`, …) and exposes `.Within(timeout)` for asynchronous execution. The
251+
assertion polls `ITimerMock.ExecutionCount` until the quantifier is satisfied
252+
or the timeout expires — 30 seconds by default.
253+
254+
```csharp
255+
await That(timer).Executed().AtLeast(2.Times()).Within(100.Milliseconds());
256+
```
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
using System;
2+
using System.Text;
3+
using System.Threading;
4+
using System.Threading.Tasks;
5+
using aweXpect.Core;
6+
using aweXpect.Core.Constraints;
7+
using aweXpect.Core.EvaluationContext;
8+
using aweXpect.Options;
9+
using Testably.Abstractions.Testing.TimeSystem;
10+
11+
namespace aweXpect.Testably.Helpers;
12+
13+
internal static class TimerConstraints
14+
{
15+
internal sealed class TimerExecutedConstraint(
16+
string it,
17+
ExpectationGrammars grammars,
18+
Quantifier quantifier,
19+
NotificationTimeoutOptions options)
20+
: ConstraintResult.WithValue<ITimerMock>(grammars),
21+
IAsyncContextConstraint<ITimerMock>
22+
{
23+
private long _executionCount;
24+
25+
public async Task<ConstraintResult> IsMetBy(ITimerMock actual,
26+
IEvaluationContext context,
27+
CancellationToken cancellationToken)
28+
{
29+
Actual = actual;
30+
if (actual is null)
31+
{
32+
Outcome = Outcome.Failure;
33+
return this;
34+
}
35+
36+
TimeSpan timeout = options.Timeout;
37+
using CancellationTokenSource deadlineCts =
38+
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
39+
deadlineCts.CancelAfter(timeout);
40+
CancellationToken deadlineToken = deadlineCts.Token;
41+
42+
TimeSpan pollInterval = TimeSpan.FromMilliseconds(10);
43+
while (true)
44+
{
45+
_executionCount = actual.ExecutionCount;
46+
if (quantifier.Check(ToInt(_executionCount), false) is not null)
47+
{
48+
break;
49+
}
50+
51+
if (deadlineToken.IsCancellationRequested)
52+
{
53+
break;
54+
}
55+
56+
try
57+
{
58+
await Task.Delay(pollInterval, deadlineToken).ConfigureAwait(false);
59+
}
60+
catch (OperationCanceledException)
61+
{
62+
// deadline hit
63+
}
64+
}
65+
66+
cancellationToken.ThrowIfCancellationRequested();
67+
68+
_executionCount = actual.ExecutionCount;
69+
Outcome = quantifier.Check(ToInt(_executionCount), true) == true
70+
? Outcome.Success
71+
: Outcome.Failure;
72+
return this;
73+
}
74+
75+
private static int ToInt(long value)
76+
=> value > int.MaxValue ? int.MaxValue : (int)value;
77+
78+
protected override void AppendNormalExpectation(StringBuilder stringBuilder, string? indentation = null)
79+
=> AppendExpectation(stringBuilder, false);
80+
81+
protected override void AppendNormalResult(StringBuilder stringBuilder, string? indentation = null)
82+
=> AppendCount(stringBuilder);
83+
84+
protected override void AppendNegatedExpectation(StringBuilder stringBuilder, string? indentation = null)
85+
=> AppendExpectation(stringBuilder, true);
86+
87+
protected override void AppendNegatedResult(StringBuilder stringBuilder, string? indentation = null)
88+
=> AppendCount(stringBuilder);
89+
90+
private void AppendExpectation(StringBuilder stringBuilder, bool negated)
91+
{
92+
if (quantifier.IsNever)
93+
{
94+
stringBuilder.Append(negated ? "executed at least once" : "did not execute");
95+
}
96+
else
97+
{
98+
stringBuilder.Append(negated ? "did not execute " : "executed ").Append(quantifier);
99+
}
100+
101+
stringBuilder.Append(options);
102+
}
103+
104+
private void AppendCount(StringBuilder stringBuilder)
105+
{
106+
if (Actual is null)
107+
{
108+
stringBuilder.Append(it).Append(" was <null>");
109+
return;
110+
}
111+
112+
stringBuilder.Append(it).Append(" was ");
113+
if (_executionCount == 0)
114+
{
115+
stringBuilder.Append("not executed");
116+
return;
117+
}
118+
119+
stringBuilder.Append("executed ");
120+
AppendTimes(stringBuilder, _executionCount);
121+
}
122+
123+
private static void AppendTimes(StringBuilder stringBuilder, long count)
124+
{
125+
switch (count)
126+
{
127+
case 1:
128+
stringBuilder.Append("once");
129+
break;
130+
case 2:
131+
stringBuilder.Append("twice");
132+
break;
133+
default:
134+
stringBuilder.Append(count).Append(" times");
135+
break;
136+
}
137+
}
138+
}
139+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using System;
2+
using aweXpect.Core;
3+
using aweXpect.Options;
4+
using aweXpect.Results;
5+
using aweXpect.Testably.Helpers;
6+
using Testably.Abstractions.Testing.TimeSystem;
7+
8+
namespace aweXpect.Testably.Results;
9+
10+
/// <summary>
11+
/// The result for <see cref="TimerExtensions.Executed(aweXpect.Core.IThat{ITimerMock})" />.
12+
/// </summary>
13+
public class TimerExecutedResult
14+
: CountResult<ITimerMock, IThat<ITimerMock>, TimerExecutedResult>
15+
{
16+
private readonly NotificationTimeoutOptions _options;
17+
18+
internal TimerExecutedResult(
19+
ExpectationBuilder expectationBuilder,
20+
IThat<ITimerMock> subject,
21+
Quantifier quantifier,
22+
NotificationTimeoutOptions options)
23+
: base(expectationBuilder, subject, quantifier)
24+
{
25+
_options = options;
26+
}
27+
28+
/// <summary>
29+
/// Allows a <paramref name="timeout" /> for waiting for asynchronous timer executions.
30+
/// </summary>
31+
public TimerExecutedResult Within(TimeSpan timeout)
32+
{
33+
_options.Within(timeout);
34+
return this;
35+
}
36+
}
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using aweXpect.Core;
2+
using aweXpect.Options;
3+
using aweXpect.Testably.Helpers;
4+
using aweXpect.Testably.Results;
5+
using Testably.Abstractions.Testing.TimeSystem;
6+
7+
namespace aweXpect.Testably;
8+
9+
/// <summary>
10+
/// Extensions for <see cref="ITimerMock" />.
11+
/// </summary>
12+
public static class TimerExtensions
13+
{
14+
/// <summary>
15+
/// Verifies that the <see cref="ITimerMock" /> callback was executed.
16+
/// </summary>
17+
/// <remarks>
18+
/// Polls <see cref="ITimerMock.ExecutionCount" /> until either the quantifier is satisfied
19+
/// or the timeout expires — 30 seconds by default; use <c>.Within(timeout)</c> to override.
20+
/// </remarks>
21+
public static TimerExecutedResult Executed(
22+
this IThat<ITimerMock> subject)
23+
=> ExecutedCore(subject, null);
24+
25+
private static TimerExecutedResult ExecutedCore(
26+
IThat<ITimerMock> subject,
27+
Times? times)
28+
{
29+
Quantifier quantifier = new();
30+
if (times.HasValue)
31+
{
32+
quantifier.Exactly(times.Value.Value);
33+
}
34+
35+
NotificationTimeoutOptions options = new();
36+
return new TimerExecutedResult(
37+
subject.Get().ExpectationBuilder.AddConstraint((it, grammars)
38+
=> new TimerConstraints.TimerExecutedConstraint(it, grammars, quantifier, options)),
39+
subject,
40+
quantifier,
41+
options);
42+
}
43+
}

Tests/aweXpect.Testably.Api.Tests/Expected/aweXpect.Testably_net10.0.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,10 @@ namespace aweXpect.Testably
9393
{
9494
public static aweXpect.Testably.Recorded.RecordedFileSystemStatistics Recorded(this aweXpect.Core.IThat<Testably.Abstractions.Testing.Statistics.IFileSystemStatistics> subject) { }
9595
}
96+
public static class TimerExtensions
97+
{
98+
public static aweXpect.Testably.Results.TimerExecutedResult Executed(this aweXpect.Core.IThat<Testably.Abstractions.Testing.TimeSystem.ITimerMock> subject) { }
99+
}
96100
}
97101
namespace aweXpect.Testably.Recorded
98102
{
@@ -468,6 +472,10 @@ namespace aweXpect.Testably.Results
468472
}
469473
public sealed class RecordedMethodCallResult : aweXpect.Results.CountResult<Testably.Abstractions.Testing.Statistics.IFileSystemStatistics, aweXpect.Core.IThat<Testably.Abstractions.Testing.Statistics.IFileSystemStatistics>, aweXpect.Testably.Results.RecordedMethodCallResult> { }
470474
public sealed class RecordedPropertyAccessResult : aweXpect.Results.CountResult<Testably.Abstractions.Testing.Statistics.IFileSystemStatistics, aweXpect.Core.IThat<Testably.Abstractions.Testing.Statistics.IFileSystemStatistics>, aweXpect.Testably.Results.RecordedPropertyAccessResult> { }
475+
public class TimerExecutedResult : aweXpect.Results.CountResult<Testably.Abstractions.Testing.TimeSystem.ITimerMock, aweXpect.Core.IThat<Testably.Abstractions.Testing.TimeSystem.ITimerMock>, aweXpect.Testably.Results.TimerExecutedResult>
476+
{
477+
public aweXpect.Testably.Results.TimerExecutedResult Within(System.TimeSpan timeout) { }
478+
}
471479
public class TriggeredNotificationResult : aweXpect.Results.CountResult<Testably.Abstractions.Testing.MockFileSystem, aweXpect.Core.IThat<Testably.Abstractions.Testing.MockFileSystem>, aweXpect.Testably.Results.TriggeredNotificationResult>
472480
{
473481
public aweXpect.Testably.Results.TriggeredNotificationResult Which(System.Action<aweXpect.Core.IThat<Testably.Abstractions.Testing.FileSystem.ChangeDescription>> expectation) { }

Tests/aweXpect.Testably.Api.Tests/Expected/aweXpect.Testably_net8.0.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ namespace aweXpect.Testably
8383
{
8484
public static aweXpect.Testably.Recorded.RecordedFileSystemStatistics Recorded(this aweXpect.Core.IThat<Testably.Abstractions.Testing.Statistics.IFileSystemStatistics> subject) { }
8585
}
86+
public static class TimerExtensions
87+
{
88+
public static aweXpect.Testably.Results.TimerExecutedResult Executed(this aweXpect.Core.IThat<Testably.Abstractions.Testing.TimeSystem.ITimerMock> subject) { }
89+
}
8690
}
8791
namespace aweXpect.Testably.Recorded
8892
{
@@ -458,6 +462,10 @@ namespace aweXpect.Testably.Results
458462
}
459463
public sealed class RecordedMethodCallResult : aweXpect.Results.CountResult<Testably.Abstractions.Testing.Statistics.IFileSystemStatistics, aweXpect.Core.IThat<Testably.Abstractions.Testing.Statistics.IFileSystemStatistics>, aweXpect.Testably.Results.RecordedMethodCallResult> { }
460464
public sealed class RecordedPropertyAccessResult : aweXpect.Results.CountResult<Testably.Abstractions.Testing.Statistics.IFileSystemStatistics, aweXpect.Core.IThat<Testably.Abstractions.Testing.Statistics.IFileSystemStatistics>, aweXpect.Testably.Results.RecordedPropertyAccessResult> { }
465+
public class TimerExecutedResult : aweXpect.Results.CountResult<Testably.Abstractions.Testing.TimeSystem.ITimerMock, aweXpect.Core.IThat<Testably.Abstractions.Testing.TimeSystem.ITimerMock>, aweXpect.Testably.Results.TimerExecutedResult>
466+
{
467+
public aweXpect.Testably.Results.TimerExecutedResult Within(System.TimeSpan timeout) { }
468+
}
461469
public class TriggeredNotificationResult : aweXpect.Results.CountResult<Testably.Abstractions.Testing.MockFileSystem, aweXpect.Core.IThat<Testably.Abstractions.Testing.MockFileSystem>, aweXpect.Testably.Results.TriggeredNotificationResult>
462470
{
463471
public aweXpect.Testably.Results.TriggeredNotificationResult Which(System.Action<aweXpect.Core.IThat<Testably.Abstractions.Testing.FileSystem.ChangeDescription>> expectation) { }

Tests/aweXpect.Testably.Api.Tests/Expected/aweXpect.Testably_netstandard2.0.txt

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,10 @@ namespace aweXpect.Testably
8383
{
8484
public static aweXpect.Testably.Recorded.RecordedFileSystemStatistics Recorded(this aweXpect.Core.IThat<Testably.Abstractions.Testing.Statistics.IFileSystemStatistics> subject) { }
8585
}
86+
public static class TimerExtensions
87+
{
88+
public static aweXpect.Testably.Results.TimerExecutedResult Executed(this aweXpect.Core.IThat<Testably.Abstractions.Testing.TimeSystem.ITimerMock> subject) { }
89+
}
8690
}
8791
namespace aweXpect.Testably.Recorded
8892
{
@@ -456,6 +460,10 @@ namespace aweXpect.Testably.Results
456460
}
457461
public sealed class RecordedMethodCallResult : aweXpect.Results.CountResult<Testably.Abstractions.Testing.Statistics.IFileSystemStatistics, aweXpect.Core.IThat<Testably.Abstractions.Testing.Statistics.IFileSystemStatistics>, aweXpect.Testably.Results.RecordedMethodCallResult> { }
458462
public sealed class RecordedPropertyAccessResult : aweXpect.Results.CountResult<Testably.Abstractions.Testing.Statistics.IFileSystemStatistics, aweXpect.Core.IThat<Testably.Abstractions.Testing.Statistics.IFileSystemStatistics>, aweXpect.Testably.Results.RecordedPropertyAccessResult> { }
463+
public class TimerExecutedResult : aweXpect.Results.CountResult<Testably.Abstractions.Testing.TimeSystem.ITimerMock, aweXpect.Core.IThat<Testably.Abstractions.Testing.TimeSystem.ITimerMock>, aweXpect.Testably.Results.TimerExecutedResult>
464+
{
465+
public aweXpect.Testably.Results.TimerExecutedResult Within(System.TimeSpan timeout) { }
466+
}
459467
public class TriggeredNotificationResult : aweXpect.Results.CountResult<Testably.Abstractions.Testing.MockFileSystem, aweXpect.Core.IThat<Testably.Abstractions.Testing.MockFileSystem>, aweXpect.Testably.Results.TriggeredNotificationResult>
460468
{
461469
public aweXpect.Testably.Results.TriggeredNotificationResult Which(System.Action<aweXpect.Core.IThat<Testably.Abstractions.Testing.FileSystem.ChangeDescription>> expectation) { }

0 commit comments

Comments
 (0)