Skip to content

Commit a020f0f

Browse files
authored
Latency progress reporting (#95)
1 parent 39634f0 commit a020f0f

File tree

8 files changed

+348
-16
lines changed

8 files changed

+348
-16
lines changed

src/NetPace.Core.Tests/OoklaSpeedtestTests.Guards.cs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,86 @@ public async Task GetServerLatencyAsync_ServerUrl_EmptyOrWhitespace_ThrowsArgume
8989
Assert.Equal("serverUrl", exception.ParamName);
9090
}
9191

92+
[Fact]
93+
public async Task GetServerLatencyAsync_WithProgress_Server_Null_ThrowsArgumentNullException()
94+
{
95+
// Given
96+
var speedtest = new OoklaSpeedtest();
97+
IServer? server = null;
98+
99+
// When
100+
var exception = await Assert.ThrowsAsync<ArgumentNullException>(
101+
() => speedtest.GetServerLatencyAsync(server!, _ => { }));
102+
103+
// Then
104+
Assert.Equal("server", exception.ParamName);
105+
}
106+
107+
[Fact]
108+
public async Task GetServerLatencyAsync_WithProgress_IServer_UrlNull_ThrowsArgumentNullException()
109+
{
110+
// Given
111+
var speedtest = new OoklaSpeedtest();
112+
var server = new Server { Url = null!, Sponsor = "Test", Location = "Test" };
113+
114+
// When
115+
var exception = await Assert.ThrowsAsync<ArgumentNullException>(
116+
() => speedtest.GetServerLatencyAsync(server, _ => { }));
117+
118+
// Then
119+
Assert.Equal("server.Url", exception.ParamName);
120+
}
121+
122+
[Theory]
123+
[InlineData("")]
124+
[InlineData(" ")]
125+
[InlineData("\t")]
126+
public async Task GetServerLatencyAsync_WithProgress_IServer_UrlEmptyOrWhitespace_ThrowsArgumentException(string url)
127+
{
128+
// Given
129+
var speedtest = new OoklaSpeedtest();
130+
var server = new Server { Url = url, Sponsor = "Test", Location = "Test" };
131+
132+
// When
133+
var exception = await Assert.ThrowsAsync<ArgumentException>(
134+
() => speedtest.GetServerLatencyAsync(server, _ => { }));
135+
136+
// Then
137+
Assert.Contains("server.Url", exception.Message);
138+
}
139+
140+
[Fact]
141+
public async Task GetServerLatencyAsync_ByUrl_WithProgress_ServerUrl_Null_ThrowsArgumentNullException()
142+
{
143+
// Given
144+
var speedtest = new OoklaSpeedtest();
145+
string? serverUrl = null;
146+
147+
// When
148+
var exception = await Assert.ThrowsAsync<ArgumentNullException>(
149+
() => speedtest.GetServerLatencyAsync(serverUrl!, _ => { }));
150+
151+
// Then
152+
Assert.Equal("serverUrl", exception.ParamName);
153+
}
154+
155+
[Theory]
156+
[InlineData("")]
157+
[InlineData(" ")]
158+
[InlineData("\t")]
159+
public async Task GetServerLatencyAsync_ByUrl_WithProgress_ServerUrl_EmptyOrWhitespace_ThrowsArgumentException(string serverUrl)
160+
{
161+
// Given
162+
var speedtest = new OoklaSpeedtest();
163+
164+
// When
165+
var exception = await Assert.ThrowsAsync<ArgumentException>(
166+
() => speedtest.GetServerLatencyAsync(serverUrl, _ => { }));
167+
168+
// Then
169+
Assert.Equal("serverUrl", exception.ParamName);
170+
}
171+
92172
[Fact]
93173
public async Task GetFastestServerByLatencyAsync_Servers_Null_ThrowsArgumentNullException()
94174
{

src/NetPace.Core.Tests/OoklaSpeedtestTests.cs

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,163 @@ public async Task GetServerLatencyAsync_ShouldCancel_WhenTokenIsCancelled()
242242
cts.IsCancellationRequested.ShouldBeTrue();
243243
}
244244

245+
// --- GetServerLatencyAsync with Progress ---
246+
247+
[Fact]
248+
public async Task GetServerLatencyAsync_WithProgress_ShouldReportProgress_ForEachIteration()
249+
{
250+
// Given
251+
using var mockHttp = new MockHttpMessageHandler();
252+
mockHttp.When("http://testserver.com/latency.txt")
253+
.Respond("text/plain", "test=test");
254+
255+
var httpClient = mockHttp.ToHttpClient();
256+
var settings = new OoklaSpeedtestSettings
257+
{
258+
LatencyTest = new()
259+
{
260+
LatencyTestIterations = 4
261+
}
262+
};
263+
264+
var speedtest = new OoklaSpeedtest(settings, httpClient);
265+
var server = new Server { Url = "http://testserver.com/", Sponsor = "Sponsor", Location = "Location" };
266+
var progressReports = new List<int>();
267+
268+
// When
269+
var result = await speedtest.GetServerLatencyAsync(server, progress => progressReports.Add(progress.PercentageComplete));
270+
271+
// Then
272+
result.ShouldNotBeNull();
273+
result.Server.ShouldBe(server);
274+
result.Latency.ShouldBeGreaterThanOrEqualTo(0);
275+
progressReports.ShouldBe(new[] { 25, 50, 75, 100 });
276+
}
277+
278+
[Fact]
279+
public async Task GetServerLatencyAsync_ByUrl_WithProgress_ShouldReportProgress_ForEachIteration()
280+
{
281+
// Given
282+
using var mockHttp = new MockHttpMessageHandler();
283+
mockHttp.When("http://testserver.com/latency.txt")
284+
.Respond("text/plain", "test=test");
285+
286+
var httpClient = mockHttp.ToHttpClient();
287+
var settings = new OoklaSpeedtestSettings
288+
{
289+
LatencyTest = new()
290+
{
291+
LatencyTestIterations = 4
292+
}
293+
};
294+
295+
var speedtest = new OoklaSpeedtest(settings, httpClient);
296+
var progressReports = new List<int>();
297+
298+
// When
299+
var result = await speedtest.GetServerLatencyAsync("http://testserver.com/", progress => progressReports.Add(progress.PercentageComplete));
300+
301+
// Then
302+
result.ShouldNotBeNull();
303+
result.Server.Url.ShouldBe("http://testserver.com/");
304+
result.Latency.ShouldBeGreaterThanOrEqualTo(0);
305+
progressReports.ShouldBe(new[] { 25, 50, 75, 100 });
306+
}
307+
308+
[Theory]
309+
[InlineData(1, new[] { 100 })]
310+
[InlineData(2, new[] { 50, 100 })]
311+
[InlineData(5, new[] { 20, 40, 60, 80, 100 })]
312+
[InlineData(10, new[] { 10, 20, 30, 40, 50, 60, 70, 80, 90, 100 })]
313+
public async Task GetServerLatencyAsync_WithProgress_CustomIterations_ShouldReportCorrectPercentages(int iterations, int[] expectedProgress)
314+
{
315+
// Given
316+
using var mockHttp = new MockHttpMessageHandler();
317+
mockHttp.When("http://testserver.com/latency.txt")
318+
.Respond("text/plain", "test=test");
319+
320+
var httpClient = mockHttp.ToHttpClient();
321+
var settings = new OoklaSpeedtestSettings
322+
{
323+
LatencyTest = new()
324+
{
325+
LatencyTestIterations = iterations
326+
}
327+
};
328+
329+
var speedtest = new OoklaSpeedtest(settings, httpClient);
330+
var server = new Server { Url = "http://testserver.com/", Sponsor = "Sponsor", Location = "Location" };
331+
var progressReports = new List<int>();
332+
333+
// When
334+
var result = await speedtest.GetServerLatencyAsync(server, progress => progressReports.Add(progress.PercentageComplete));
335+
336+
// Then
337+
result.ShouldNotBeNull();
338+
progressReports.ShouldBe(expectedProgress);
339+
}
340+
341+
[Fact]
342+
public async Task GetServerLatencyAsync_WithProgress_ShouldCancel_WhenTokenIsCancelled()
343+
{
344+
// Given
345+
using var mockHttp = new MockHttpMessageHandler();
346+
mockHttp.When("*").Respond(async _ =>
347+
{
348+
// Simulate slow response.
349+
await Task.Delay(1000);
350+
return new HttpResponseMessage(HttpStatusCode.OK);
351+
});
352+
353+
var httpClient = mockHttp.ToHttpClient();
354+
var speedtest = new OoklaSpeedtest(httpClientOverride: httpClient);
355+
var server = new Server { Url = "http://testserver.com/", Sponsor = "Sponsor", Location = "Location" };
356+
var progressReports = new List<int>();
357+
358+
using var cts = new CancellationTokenSource();
359+
cts.CancelAfter(200);
360+
361+
// When
362+
var exception = await Record.ExceptionAsync(() => speedtest.GetServerLatencyAsync(server, progress => progressReports.Add(progress.PercentageComplete), cts.Token));
363+
364+
// Then
365+
exception.ShouldNotBeNull();
366+
exception.ShouldBeAssignableTo<OperationCanceledException>();
367+
cts.IsCancellationRequested.ShouldBeTrue();
368+
}
369+
370+
[Fact]
371+
public async Task GetServerLatencyAsync_WithProgress_ShouldPropagateException_WhenProgressCallbackThrows()
372+
{
373+
// Given
374+
using var mockHttp = new MockHttpMessageHandler();
375+
mockHttp.When("http://testserver.com/latency.txt")
376+
.Respond("text/plain", "test=test");
377+
378+
var httpClient = mockHttp.ToHttpClient();
379+
var settings = new OoklaSpeedtestSettings
380+
{
381+
LatencyTest = new()
382+
{
383+
LatencyTestIterations = 4
384+
}
385+
};
386+
387+
var speedtest = new OoklaSpeedtest(settings, httpClient);
388+
var server = new Server { Url = "http://testserver.com/", Sponsor = "Sponsor", Location = "Location" };
389+
390+
// When
391+
var exception = await Record.ExceptionAsync(() => speedtest.GetServerLatencyAsync(server, progress =>
392+
{
393+
throw new InvalidOperationException("Progress callback failed");
394+
}));
395+
396+
// Then
397+
exception.ShouldNotBeNull();
398+
exception.ShouldBeOfType<InvalidOperationException>();
399+
exception.Message.ShouldBe("Progress callback failed");
400+
}
401+
245402
// --- GetFastestServerByLatencyAsync ---
246403

247404
[Fact]

src/NetPace.Core/Clients/Ookla/OoklaSpeedtest.cs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -42,23 +42,35 @@ public async Task<IServer[]> GetServersAsync(CancellationToken cancellationToken
4242

4343
/// <inheritdoc/>
4444
public async Task<ServerLatencyResult> GetServerLatencyAsync(IServer server, CancellationToken cancellationToken = default)
45+
{
46+
return await GetServerLatencyAsync(server, (_) => { }, cancellationToken);
47+
}
48+
49+
/// <inheritdoc/>
50+
public async Task<ServerLatencyResult> GetServerLatencyAsync(IServer server, Action<SpeedTestProgress> UpdateProgress, CancellationToken cancellationToken = default)
4551
{
4652
ArgumentNullException.ThrowIfNull(server);
4753
ArgumentException.ThrowIfNullOrWhiteSpace(server.Url);
4854

49-
return await GetServerLatencyAsync(server, httpClient, settings.LatencyTest.DefaultHttpTimeoutMilliseconds, settings.LatencyTest.LatencyTestIterations, cancellationToken);
55+
return await GetServerLatencyAsync(server, httpClient, settings.LatencyTest.DefaultHttpTimeoutMilliseconds, settings.LatencyTest.LatencyTestIterations, UpdateProgress, cancellationToken);
5056
}
5157

5258
/// <inheritdoc/>
5359
public async Task<ServerLatencyResult> GetServerLatencyAsync(string serverUrl, CancellationToken cancellationToken = default)
60+
{
61+
return await GetServerLatencyAsync(serverUrl, (_) => { }, cancellationToken);
62+
}
63+
64+
/// <inheritdoc/>
65+
public async Task<ServerLatencyResult> GetServerLatencyAsync(string serverUrl, Action<SpeedTestProgress> UpdateProgress, CancellationToken cancellationToken = default)
5466
{
5567
ArgumentException.ThrowIfNullOrWhiteSpace(serverUrl);
5668

5769
var server = new Server() { Sponsor = "(Unknown)", Url = serverUrl };
58-
return await GetServerLatencyAsync(server, httpClient, settings.LatencyTest.DefaultHttpTimeoutMilliseconds, settings.LatencyTest.LatencyTestIterations, cancellationToken);
70+
return await GetServerLatencyAsync(server, httpClient, settings.LatencyTest.DefaultHttpTimeoutMilliseconds, settings.LatencyTest.LatencyTestIterations, UpdateProgress, cancellationToken);
5971
}
6072

61-
private static async Task<ServerLatencyResult> GetServerLatencyAsync(IServer server, HttpClient httpClient, int httpTimeoutMilliseconds, int maxIterations, CancellationToken cancellationToken)
73+
private static async Task<ServerLatencyResult> GetServerLatencyAsync(IServer server, HttpClient httpClient, int httpTimeoutMilliseconds, int maxIterations, Action<SpeedTestProgress> UpdateProgress, CancellationToken cancellationToken)
6274
{
6375
var latencyUrl = GetBaseUrl(server.Url) + "latency.txt";
6476
var stopwatch = new Stopwatch();
@@ -76,6 +88,10 @@ private static async Task<ServerLatencyResult> GetServerLatencyAsync(IServer ser
7688
{
7789
throw new InvalidOperationException("Server returned incorrect test string for latency.txt");
7890
}
91+
92+
// Report progress after each iteration
93+
var percentageComplete = (iteration + 1) * 100 / maxIterations;
94+
UpdateProgress(new SpeedTestProgress { PercentageComplete = percentageComplete });
7995
}
8096

8197
// Calculate the average server latency.
@@ -109,7 +125,7 @@ public async Task<ServerLatencyResult> GetFastestServerByLatencyAsync(IServer[]
109125

110126
try
111127
{
112-
var latencyResult = await GetServerLatencyAsync(server, httpClient, httpTimeoutMilliseconds, settings.LatencyTest.LatencyTestIterations, cancellationToken);
128+
var latencyResult = await GetServerLatencyAsync(server, httpClient, httpTimeoutMilliseconds, settings.LatencyTest.LatencyTestIterations, _ => { }, cancellationToken);
113129

114130
if (latencyResult.Latency < fastestLatency)
115131
{

src/NetPace.Core/Clients/Testing/FaultySpeedTester.cs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,25 @@ public Task<ServerLatencyResult> GetServerLatencyAsync(IServer server, Cancellat
5656
return inner.GetServerLatencyAsync(server, cancellationToken);
5757
}
5858

59+
/// <inheritdoc/>
60+
public Task<ServerLatencyResult> GetServerLatencyAsync(IServer server, Action<SpeedTestProgress> UpdateProgress, CancellationToken cancellationToken = default)
61+
{
62+
AssertNotFaulted(server, nameof(GetServerLatencyAsync));
63+
return inner.GetServerLatencyAsync(server, UpdateProgress, cancellationToken);
64+
}
65+
5966
/// <inheritdoc/>
6067
public async Task<ServerLatencyResult> GetServerLatencyAsync(string serverUrl, CancellationToken cancellationToken = default)
6168
{
62-
var result = await inner.GetServerLatencyAsync(serverUrl, cancellationToken);
69+
var result = await inner.GetServerLatencyAsync(serverUrl, cancellationToken);
70+
AssertNotFaulted(result.Server, nameof(GetServerLatencyAsync));
71+
return result;
72+
}
73+
74+
/// <inheritdoc/>
75+
public async Task<ServerLatencyResult> GetServerLatencyAsync(string serverUrl, Action<SpeedTestProgress> UpdateProgress, CancellationToken cancellationToken = default)
76+
{
77+
var result = await inner.GetServerLatencyAsync(serverUrl, UpdateProgress, cancellationToken);
6378
AssertNotFaulted(result.Server, nameof(GetServerLatencyAsync));
6479
return result;
6580
}

src/NetPace.Core/Clients/Testing/SpeedTestMock.cs

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@ public sealed class SpeedTestMock : ISpeedTestService
1515
/// Gets or sets the delegate that provides behavior for <see cref="GetServerLatencyAsync(IServer, CancellationToken)"/>.
1616
/// If null, the method will throw <see cref="NotImplementedException"/> when called.
1717
/// </summary>
18-
public Func<IServer, CancellationToken, Task<ServerLatencyResult>>? GetServerLatencyAsyncFunc { get; set; }
18+
public Func<IServer, Action<SpeedTestProgress>, CancellationToken, Task<ServerLatencyResult>>? GetServerLatencyAsyncFunc { get; set; }
1919

2020
/// <summary>
2121
/// Gets or sets the delegate that provides behavior for <see cref="GetServerLatencyAsync(string, CancellationToken)"/>.
2222
/// If null, the method will throw <see cref="NotImplementedException"/> when called.
2323
/// </summary>
24-
public Func<string, CancellationToken, Task<ServerLatencyResult>>? GetServerLatencyByServerUrlAsyncFunc { get; set; }
24+
public Func<string, Action<SpeedTestProgress>, CancellationToken, Task<ServerLatencyResult>>? GetServerLatencyByServerUrlAsyncFunc { get; set; }
2525

2626
/// <summary>
2727
/// Gets or sets the delegate that provides behavior for <see cref="GetFastestServerByLatencyAsync"/>.
@@ -53,15 +53,31 @@ public Task<IServer[]> GetServersAsync(CancellationToken cancellationToken = def
5353
public Task<ServerLatencyResult> GetServerLatencyAsync(IServer server, CancellationToken cancellationToken = default)
5454
{
5555
if (GetServerLatencyAsyncFunc != null)
56-
return GetServerLatencyAsyncFunc(server, cancellationToken);
56+
return GetServerLatencyAsyncFunc(server, _ => { }, cancellationToken);
57+
throw new NotImplementedException(nameof(GetServerLatencyAsync));
58+
}
59+
60+
/// <inheritdoc/>
61+
public Task<ServerLatencyResult> GetServerLatencyAsync(IServer server, Action<SpeedTestProgress> UpdateProgress, CancellationToken cancellationToken = default)
62+
{
63+
if (GetServerLatencyAsyncFunc != null)
64+
return GetServerLatencyAsyncFunc(server, UpdateProgress, cancellationToken);
5765
throw new NotImplementedException(nameof(GetServerLatencyAsync));
5866
}
5967

6068
/// <inheritdoc/>
6169
public Task<ServerLatencyResult> GetServerLatencyAsync(string serverUrl, CancellationToken cancellationToken = default)
6270
{
6371
if (GetServerLatencyByServerUrlAsyncFunc != null)
64-
return GetServerLatencyByServerUrlAsyncFunc(serverUrl, cancellationToken);
72+
return GetServerLatencyByServerUrlAsyncFunc(serverUrl, _ => { }, cancellationToken);
73+
throw new NotImplementedException(nameof(GetServerLatencyAsync));
74+
}
75+
76+
/// <inheritdoc/>
77+
public Task<ServerLatencyResult> GetServerLatencyAsync(string serverUrl, Action<SpeedTestProgress> UpdateProgress, CancellationToken cancellationToken = default)
78+
{
79+
if (GetServerLatencyByServerUrlAsyncFunc != null)
80+
return GetServerLatencyByServerUrlAsyncFunc(serverUrl, UpdateProgress, cancellationToken);
6581
throw new NotImplementedException(nameof(GetServerLatencyAsync));
6682
}
6783

0 commit comments

Comments
 (0)