Skip to content

Commit 727d382

Browse files
committed
feat: allow waiting for max render count instead of timeout
1 parent 9f81a91 commit 727d382

File tree

11 files changed

+370
-72
lines changed

11 files changed

+370
-72
lines changed

src/bunit.core/Extensions/WaitForHelpers/RenderedFragmentWaitForHelperExtensions.cs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,34 @@ public static void WaitForState(this IRenderedFragmentBase renderedFragment, Fun
3737
}
3838
}
3939

40+
/// <summary>
41+
/// Wait until the provided <paramref name="statePredicate"/> action returns true,
42+
/// or the <paramref name="maxRenderCount"/> is reached.
43+
///
44+
/// The <paramref name="statePredicate"/> is evaluated initially, and then each time
45+
/// the <paramref name="renderedFragment"/> renders.
46+
/// </summary>
47+
/// <param name="renderedFragment">The render fragment or component to attempt to verify state against.</param>
48+
/// <param name="statePredicate">The predicate to invoke after each render, which must returns <c>true</c> when the desired state has been reached.</param>
49+
/// <param name="maxRenderCount">The number of renders of <paramref name="renderedFragment"/> should wait at most.</param>
50+
/// <exception cref="WaitForFailedException">Thrown if the <paramref name="statePredicate"/> throw an exception during invocation, or if the timeout has been reached. See the inner exception for details.</exception>
51+
/// <remarks>
52+
/// If a debugger is attached the timeout is set to <see cref="Timeout.InfiniteTimeSpan" />, giving the possibility to debug without the timeout triggering.
53+
/// </remarks>
54+
public static void WaitForState(this IRenderedFragmentBase renderedFragment, Func<bool> statePredicate, int maxRenderCount)
55+
{
56+
using var waiter = new WaitForStateHelper(renderedFragment, statePredicate, maxRenderCount);
57+
58+
try
59+
{
60+
waiter.WaitTask.GetAwaiter().GetResult();
61+
}
62+
catch (AggregateException e) when (e.InnerExceptions.Count == 1)
63+
{
64+
ExceptionDispatchInfo.Capture(e.InnerExceptions[0]).Throw();
65+
}
66+
}
67+
4068
/// <summary>
4169
/// Wait until the provided <paramref name="statePredicate"/> action returns true,
4270
/// or the <paramref name="timeout"/> is reached (default is one second).
@@ -55,6 +83,24 @@ internal static async Task WaitForStateAsync(this IRenderedFragmentBase rendered
5583
await waiter.WaitTask;
5684
}
5785

86+
/// <summary>
87+
/// Wait until the provided <paramref name="statePredicate"/> action returns true,
88+
/// or the <paramref name="maxRenderCount"/> is reached.
89+
///
90+
/// The <paramref name="statePredicate"/> is evaluated initially, and then each time
91+
/// the <paramref name="renderedFragment"/> renders.
92+
/// </summary>
93+
/// <param name="renderedFragment">The render fragment or component to attempt to verify state against.</param>
94+
/// <param name="statePredicate">The predicate to invoke after each render, which must returns <c>true</c> when the desired state has been reached.</param>
95+
/// <param name="maxRenderCount">The number of renders of <paramref name="renderedFragment"/> should wait at most.</param>
96+
/// <exception cref="WaitForFailedException">Thrown if the <paramref name="statePredicate"/> throw an exception during invocation, or if the timeout has been reached. See the inner exception for details.</exception>
97+
internal static async Task WaitForStateAsync(this IRenderedFragmentBase renderedFragment, Func<bool> statePredicate, int maxRenderCount)
98+
{
99+
using var waiter = new WaitForStateHelper(renderedFragment, statePredicate, maxRenderCount);
100+
101+
await waiter.WaitTask;
102+
}
103+
58104
/// <summary>
59105
/// Wait until the provided <paramref name="assertion"/> passes (i.e. does not throw an
60106
/// exception), or the <paramref name="timeout"/> is reached (default is one second).
@@ -80,6 +126,31 @@ public static void WaitForAssertion(this IRenderedFragmentBase renderedFragment,
80126
}
81127
}
82128

129+
/// <summary>
130+
/// Wait until the provided <paramref name="assertion"/> passes (i.e. does not throw an exception),
131+
/// or the <paramref name="maxRenderCount"/> is reached.
132+
///
133+
/// The <paramref name="assertion"/> is attempted initially, and then each time the <paramref name="renderedFragment"/> renders.
134+
/// </summary>
135+
/// <param name="renderedFragment">The rendered fragment to wait for renders from and assert against.</param>
136+
/// <param name="assertion">The verification or assertion to perform.</param>
137+
/// <param name="maxRenderCount">The number of renders of <paramref name="renderedFragment"/> should wait at most.</param>
138+
/// <exception cref="WaitForFailedException">Thrown if the timeout has been reached. See the inner exception to see the captured assertion exception.</exception>
139+
[AssertionMethod]
140+
public static void WaitForAssertion(this IRenderedFragmentBase renderedFragment, Action assertion, int maxRenderCount)
141+
{
142+
using var waiter = new WaitForAssertionHelper(renderedFragment, assertion, maxRenderCount);
143+
144+
try
145+
{
146+
waiter.WaitTask.GetAwaiter().GetResult();
147+
}
148+
catch (AggregateException e) when (e.InnerExceptions.Count == 1)
149+
{
150+
ExceptionDispatchInfo.Capture(e.InnerExceptions[0]).Throw();
151+
}
152+
}
153+
83154
/// <summary>
84155
/// Wait until the provided <paramref name="assertion"/> passes (i.e. does not throw an
85156
/// exception), or the <paramref name="timeout"/> is reached (default is one second).
@@ -97,4 +168,21 @@ internal static async Task WaitForAssertionAsync(this IRenderedFragmentBase rend
97168

98169
await waiter.WaitTask;
99170
}
171+
172+
/// <summary>
173+
/// Wait until the provided <paramref name="assertion"/> passes (i.e. does not throw an exception),
174+
/// or the <paramref name="maxRenderCount"/> is reached.
175+
/// The <paramref name="assertion"/> is attempted initially, and then each time the <paramref name="renderedFragment"/> renders.
176+
/// </summary>
177+
/// <param name="renderedFragment">The rendered fragment to wait for renders from and assert against.</param>
178+
/// <param name="assertion">The verification or assertion to perform.</param>
179+
/// <param name="maxRenderCount">The number of renders of <paramref name="renderedFragment"/> should wait at most.</param>
180+
/// <exception cref="WaitForFailedException">Thrown if the timeout has been reached. See the inner exception to see the captured assertion exception.</exception>
181+
[AssertionMethod]
182+
internal static async Task WaitForAssertionAsync(this IRenderedFragmentBase renderedFragment, Action assertion, int maxRenderCount)
183+
{
184+
using var waiter = new WaitForAssertionHelper(renderedFragment, assertion, maxRenderCount);
185+
186+
await waiter.WaitTask;
187+
}
100188
}

src/bunit.core/Extensions/WaitForHelpers/WaitForAssertionHelper.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,12 @@ namespace Bunit.Extensions.WaitForHelpers;
55
/// </summary>
66
public class WaitForAssertionHelper : WaitForHelper<object?>
77
{
8+
internal const string RenderCountErrorMessage = "The assertion did not pass before the maximum render count was reached.";
89
internal const string TimeoutMessage = "The assertion did not pass within the timeout period.";
910

11+
/// <inheritdoc/>
12+
protected override string? MaxRenderCountErrorMessage => RenderCountErrorMessage;
13+
1014
/// <inheritdoc/>
1115
protected override string? TimeoutErrorMessage => TimeoutMessage;
1216

@@ -36,4 +40,29 @@ public WaitForAssertionHelper(IRenderedFragmentBase renderedFragment, Action ass
3640
},
3741
timeout)
3842
{ }
43+
44+
/// <summary>
45+
/// Initializes a new instance of the <see cref="WaitForAssertionHelper"/> class,
46+
/// which will until the provided <paramref name="assertion"/> passes (i.e. does not throw an
47+
/// or the <paramref name="maxRenderCount"/> is reached.
48+
///
49+
/// The <paramref name="assertion"/> is attempted initially, and then each time the <paramref name="renderedFragment"/> renders.
50+
/// </summary>
51+
/// <param name="renderedFragment">The rendered fragment to wait for renders from and assert against.</param>
52+
/// <param name="assertion">The verification or assertion to perform.</param>
53+
/// <param name="maxRenderCount">The number of renders of <paramref name="renderedFragment"/> should wait at most.</param>
54+
/// <remarks>
55+
/// If a debugger is attached the timeout is set to <see cref="Timeout.InfiniteTimeSpan" />, giving the possibility to debug without the timeout triggering.
56+
/// </remarks>
57+
public WaitForAssertionHelper(IRenderedFragmentBase renderedFragment, Action assertion, int maxRenderCount)
58+
: base(
59+
renderedFragment,
60+
() =>
61+
{
62+
assertion();
63+
return (true, default);
64+
},
65+
null,
66+
maxRenderCount)
67+
{ }
3968
}

src/bunit.core/Extensions/WaitForHelpers/WaitForHelper.cs

Lines changed: 69 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,22 @@ namespace Bunit.Extensions.WaitForHelpers;
1010
/// </summary>
1111
public abstract class WaitForHelper<T> : IDisposable
1212
{
13-
private readonly Timer timer;
13+
private readonly Timer? timer;
1414
private readonly TaskCompletionSource<T> checkPassedCompletionSource;
1515
private readonly Func<(bool CheckPassed, T Content)> completeChecker;
16+
private readonly int? maxRenderCount;
1617
private readonly IRenderedFragmentBase renderedFragment;
1718
private readonly ILogger<WaitForHelper<T>> logger;
1819
private readonly TestRenderer renderer;
1920
private bool isDisposed;
2021
private int checkCount;
2122
private Exception? capturedException;
2223

24+
/// <summary>
25+
/// Gets the error message passed to the user when the max render count passes.
26+
/// </summary>
27+
protected virtual string? MaxRenderCountErrorMessage { get; }
28+
2329
/// <summary>
2430
/// Gets the error message passed to the user when the wait for helper times out.
2531
/// </summary>
@@ -48,31 +54,36 @@ public abstract class WaitForHelper<T> : IDisposable
4854
protected WaitForHelper(
4955
IRenderedFragmentBase renderedFragment,
5056
Func<(bool CheckPassed, T Content)> completeChecker,
51-
TimeSpan? timeout = null)
57+
TimeSpan? timeout = null,
58+
int? maxRenderCount = null)
5259
{
5360
this.renderedFragment = renderedFragment ?? throw new ArgumentNullException(nameof(renderedFragment));
5461
this.completeChecker = completeChecker ?? throw new ArgumentNullException(nameof(completeChecker));
55-
5662
logger = renderedFragment.Services.CreateLogger<WaitForHelper<T>>();
63+
this.maxRenderCount = maxRenderCount;
5764
renderer = (TestRenderer)renderedFragment
5865
.Services
5966
.GetRequiredService<TestContextBase>()
6067
.Renderer;
6168
checkPassedCompletionSource = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
62-
timer = new Timer(_ =>
63-
{
64-
logger.LogWaiterTimedOut(renderedFragment.ComponentId);
65-
checkPassedCompletionSource.TrySetException(
66-
new WaitForFailedException(
67-
TimeoutErrorMessage ?? string.Empty,
68-
checkCount,
69-
renderedFragment.RenderCount,
70-
renderer.RenderCount,
71-
capturedException));
72-
});
73-
WaitTask = CreateWaitTask();
74-
timer.Change(GetRuntimeTimeout(timeout), Timeout.InfiniteTimeSpan);
7569

70+
timer = new Timer(static (state) =>
71+
{
72+
var @this = (WaitForHelper<T>)state!;
73+
@this.logger.LogWaiterTimedOut(@this.renderedFragment.ComponentId, @this.renderedFragment.RenderCount);
74+
@this.checkPassedCompletionSource.TrySetException(
75+
new WaitForFailedException(
76+
@this.TimeoutErrorMessage ?? string.Empty,
77+
@this.checkCount,
78+
@this.renderedFragment.RenderCount,
79+
@this.renderer.RenderCount,
80+
@this.capturedException));
81+
},
82+
this,
83+
GetRuntimeTimeout(timeout, maxRenderCount),
84+
Timeout.InfiniteTimeSpan);
85+
86+
WaitTask = CreateWaitTask();
7687
InitializeWaiting();
7788
}
7889

@@ -100,7 +111,7 @@ protected virtual void Dispose(bool disposing)
100111
return;
101112

102113
isDisposed = true;
103-
timer.Dispose();
114+
timer?.Dispose();
104115
checkPassedCompletionSource.TrySetCanceled();
105116
renderedFragment.OnAfterRender -= OnAfterRender;
106117
logger.LogWaiterDisposed(renderedFragment.ComponentId);
@@ -150,26 +161,38 @@ private void OnAfterRender(object? sender, EventArgs args)
150161

151162
try
152163
{
153-
logger.LogCheckingWaitCondition(renderedFragment.ComponentId);
164+
logger.LogCheckingWaitCondition(renderedFragment.ComponentId, renderedFragment.RenderCount);
154165

155166
var checkResult = completeChecker();
156167
checkCount++;
157168
if (checkResult.CheckPassed)
158169
{
159170
checkPassedCompletionSource.TrySetResult(checkResult.Content);
160-
logger.LogCheckCompleted(renderedFragment.ComponentId);
171+
logger.LogCheckCompleted(renderedFragment.ComponentId, renderedFragment.RenderCount);
172+
Dispose();
173+
}
174+
else if (MinimumRenderCountPassed())
175+
{
176+
checkPassedCompletionSource.TrySetException(
177+
new WaitForFailedException(
178+
MaxRenderCountErrorMessage ?? string.Empty,
179+
checkCount,
180+
renderedFragment.RenderCount,
181+
renderer.RenderCount,
182+
capturedException));
183+
161184
Dispose();
162185
}
163186
else
164187
{
165-
logger.LogCheckFailed(renderedFragment.ComponentId);
188+
logger.LogCheckFailed(renderedFragment.ComponentId, renderedFragment.RenderCount);
166189
}
167190
}
168191
catch (Exception ex)
169192
{
170193
checkCount++;
171194
capturedException = ex;
172-
logger.LogCheckThrow(renderedFragment.ComponentId, ex);
195+
logger.LogCheckThrow(renderedFragment.ComponentId, renderedFragment.RenderCount, ex);
173196

174197
if (StopWaitingOnCheckException)
175198
{
@@ -180,11 +203,28 @@ private void OnAfterRender(object? sender, EventArgs args)
180203
renderedFragment.RenderCount,
181204
renderer.RenderCount,
182205
capturedException));
206+
207+
Dispose();
208+
}
209+
210+
if (MinimumRenderCountPassed())
211+
{
212+
checkPassedCompletionSource.TrySetException(
213+
new WaitForFailedException(
214+
MaxRenderCountErrorMessage ?? string.Empty,
215+
checkCount,
216+
renderedFragment.RenderCount,
217+
renderer.RenderCount,
218+
capturedException));
219+
183220
Dispose();
184221
}
185222
}
186223
}
187224

225+
private bool MinimumRenderCountPassed()
226+
=> maxRenderCount.HasValue && renderedFragment.RenderCount >= maxRenderCount.Value;
227+
188228
private void SubscribeToOnAfterRender()
189229
{
190230
// There might not be a need to subscribe if the WaitTask has already
@@ -194,10 +234,15 @@ private void SubscribeToOnAfterRender()
194234
renderedFragment.OnAfterRender += OnAfterRender;
195235
}
196236

197-
private static TimeSpan GetRuntimeTimeout(TimeSpan? timeout)
237+
private static TimeSpan GetRuntimeTimeout(TimeSpan? timeout, int? maxRenderCount)
198238
{
199-
return Debugger.IsAttached
200-
? Timeout.InfiniteTimeSpan
239+
if (Debugger.IsAttached)
240+
{
241+
return Timeout.InfiniteTimeSpan;
242+
}
243+
244+
return maxRenderCount.HasValue
245+
? TimeSpan.FromMinutes(1)
201246
: timeout ?? TestContextBase.DefaultWaitTimeout;
202247
}
203248
}

0 commit comments

Comments
 (0)