chore: continue porting code and adding test coverage

This commit is contained in:
Stevan Freeborn
2025-05-08 01:36:04 -05:00
parent 3f4ab165ce
commit 9f35394528
12 changed files with 305 additions and 9 deletions
+1
View File
@@ -2,3 +2,4 @@
dotnet_diagnostic.CA1515.severity = none
dotnet_diagnostic.CA1707.severity = none
dotnet_diagnostic.CA2201.severity = none
dotnet_diagnostic.CA2007.severity = none
@@ -0,0 +1,199 @@
namespace StevesBot.Worker.Tests.Unit;
public class AsyncLockTests
{
[Fact]
public async Task LockAsync_WhenCalled_ItShouldReturnsNonNullRelease()
{
using var asyncLock = new AsyncLock();
var releaser = await asyncLock.LockAsync();
releaser.Should().NotBeNull();
releaser.Should().BeAssignableTo<IDisposable>();
}
[Fact]
public async Task LockAsync_WhenLockIsAcquired_ItShouldForceSubsequentCallsToWait()
{
using var asyncLock = new AsyncLock();
var lockOneAcquired = new TaskCompletionSource<bool>();
var lockTwoAttempted = new TaskCompletionSource<bool>();
var lockTwoAcquired = new TaskCompletionSource<bool>();
async Task FirstLockAction()
{
using (await asyncLock.LockAsync())
{
lockOneAcquired.SetResult(true);
await lockTwoAttempted.Task;
await Task.Delay(100);
}
}
async Task SecondLockAction()
{
await lockOneAcquired.Task;
lockTwoAttempted.SetResult(true);
using (await asyncLock.LockAsync())
{
lockTwoAcquired.SetResult(true);
}
}
var taskOne = FirstLockAction();
var taskTwo = SecondLockAction();
await Task.WhenAll(taskOne, Task.WhenAny(taskTwo, Task.Delay(500)));
lockOneAcquired.Task.IsCompleted.Should().BeTrue();
lockTwoAttempted.Task.IsCompleted.Should().BeTrue();
lockTwoAcquired.Task.IsCompleted.Should().BeTrue();
}
[Fact]
public async Task LockAsync_WhenReleaserIsDisposed_ItShouldAllowAnotherLockAcquisition()
{
using var asyncLock = new AsyncLock();
var releaser = await asyncLock.LockAsync();
releaser.Dispose();
IDisposable? newReleaser = null;
var act = async () => newReleaser = await asyncLock.LockAsync();
await act.Should().NotThrowAsync();
newReleaser.Should().NotBeNull();
newReleaser.Dispose();
}
[Fact]
public async Task LockAsync_WhenCalledWithCancellationTokenAndTokenIsCancelled_ItShouldThrowOperationCanceledException()
{
using var asyncLock = new AsyncLock();
using var cts = new CancellationTokenSource();
var initialReleaser = await asyncLock.LockAsync();
async Task<IDisposable> Act()
{
return await asyncLock.LockAsync(cts.Token);
}
var lockTask = Act();
await Task.Delay(100);
await cts.CancelAsync();
var act = () => lockTask;
await act.Should().ThrowAsync<OperationCanceledException>();
initialReleaser.Dispose();
}
[Fact]
public async Task LockAsync_WhenCalledWithAlreadyCancelledToken_ItShouldThrowOperationCanceledException()
{
using var asyncLock = new AsyncLock();
using var cts = new CancellationTokenSource();
await cts.CancelAsync();
var act = async () => await asyncLock.LockAsync(cts.Token);
await act.Should().ThrowAsync<OperationCanceledException>();
}
[Fact]
public async Task Dispose_WhenCalled_ItShouldDisposeSemaphore()
{
var asyncLock = new AsyncLock();
asyncLock.Dispose();
var act = () => asyncLock.LockAsync();
await act.Should().ThrowAsync<ObjectDisposedException>();
}
[Fact]
public void Dispose_WhenCalledMultipleTimes_ItShouldNotThrow()
{
using var asyncLock = new AsyncLock();
var act = () =>
{
asyncLock.Dispose();
asyncLock.Dispose();
};
act.Should().NotThrow();
}
[Fact]
public async Task LockAsync_WhenCalledAfterDispose_ItShouldThrowObjectDisposedException()
{
var asyncLock = new AsyncLock();
asyncLock.Dispose();
var act = () => asyncLock.LockAsync();
await act.Should().ThrowAsync<ObjectDisposedException>();
}
[Fact]
public async Task LockAsync_WhenCalled_ItShouldOnlyAllowConcurrentAccessToOneThread()
{
using var asyncLock = new AsyncLock();
var concurrentAccessCount = 0;
var maxConcurrentAccessCount = 0;
const int numberOfTasks = 10;
var tasks = new Task[numberOfTasks];
for (var i = 0; i < numberOfTasks; i++)
{
tasks[i] = Task.Run(async () =>
{
using (await asyncLock.LockAsync())
{
Interlocked.Increment(ref concurrentAccessCount);
maxConcurrentAccessCount = Math.Max(maxConcurrentAccessCount, concurrentAccessCount);
await Task.Delay(20);
Interlocked.Decrement(ref concurrentAccessCount);
}
});
}
await Task.WhenAll(tasks);
maxConcurrentAccessCount.Should().Be(1);
concurrentAccessCount.Should().Be(0);
}
[Fact]
public async Task LockAsync_WhenCalled_ItShouldPreventRaceConditionsInHighContentionScenario()
{
using var asyncLock = new AsyncLock();
var sharedResource = 0;
const int numberOfIterations = 10;
const int numberOfTasks = 10;
var tasks = new Task[numberOfTasks];
for (var i = 0; i < numberOfTasks; i++)
{
tasks[i] = Task.Run(async () =>
{
for (var j = 0; j < numberOfIterations; j++)
{
using (await asyncLock.LockAsync())
{
var temp = sharedResource;
await Task.Delay(1);
sharedResource = temp + 1;
}
}
});
}
await Task.WhenAll(tasks);
sharedResource.Should().Be(numberOfTasks * numberOfIterations);
}
}
@@ -0,0 +1,41 @@
namespace StevesBot.Worker.Tests.Unit;
public class DiscordEventTests
{
[Fact]
public void Constructor_WhenCalled_ItShouldCreateInstance()
{
var e = new DiscordEvent();
e.Should().NotBeNull();
e.Should().BeOfType<DiscordEvent>();
e.OpCode.Should().Be(0);
e.Sequence.Should().BeNull();
e.Type.Should().BeNull();
e.Data.Should().BeNull();
}
[Fact]
public void Constructor_WhenCalledWithParameters_ItShouldCreateInstance()
{
var opCode = 1;
var sequence = 2;
var type = "test";
var data = new object();
var e = new DiscordEvent
{
OpCode = opCode,
Sequence = sequence,
Type = type,
Data = data,
};
e.Should().NotBeNull();
e.Should().BeOfType<DiscordEvent>();
e.OpCode.Should().Be(opCode);
e.Sequence.Should().Be(sequence);
e.Type.Should().Be(type);
e.Data.Should().BeSameAs(data);
}
}
+3 -1
View File
@@ -1 +1,3 @@
global using StevesBot.Worker.Discord;
global using StevesBot.Worker.Discord;
global using StevesBot.Worker.Discord.Events;
global using StevesBot.Worker.Threading;
@@ -1,6 +1,6 @@
namespace StevesBot.Worker.Discord;
internal class DiscordClientException : Exception
internal sealed class DiscordClientException : Exception
{
public DiscordClientException()
{
@@ -1,8 +1,8 @@
namespace StevesBot.Worker.Discord;
internal class DiscordClientOptions
internal sealed class DiscordClientOptions
{
public string ApiUrl { get; init; } = string.Empty;
public string AppToken { get; init; } = string.Empty;
public int Intents { get; init; }
}
}
@@ -3,7 +3,7 @@ namespace StevesBot.Worker.Discord.Events;
internal record DiscordEvent
{
[JsonPropertyName("op")]
public int Op { get; init; }
public int OpCode { get; init; }
[JsonPropertyName("s")]
public int? Sequence { get; init; }
@@ -1,6 +1,6 @@
namespace StevesBot.Worker.Discord.Events;
internal class DiscordEventConverter : JsonConverter<DiscordEvent>
internal sealed class DiscordEventConverter : JsonConverter<DiscordEvent>
{
private const string OpPropertyName = "op";
@@ -1,13 +1,13 @@
namespace StevesBot.Worker.Discord.Events;
internal record HelloDiscordEvent : DiscordEvent
internal sealed record HelloDiscordEvent : DiscordEvent
{
[JsonPropertyName("d")]
public new HelloData? Data { get; init; }
}
internal record HelloData
internal sealed record HelloData
{
[JsonPropertyName("heartbeat_interval")]
public int HeartbeatInterval { get; init; }
}
}
@@ -0,0 +1,24 @@
namespace StevesBot.Worker.Threading;
internal sealed class AsyncLock : IAsyncLock
{
private readonly SemaphoreSlim _semaphore = new(1, 1);
private bool _disposed;
public async Task<IDisposable> LockAsync(CancellationToken cancellationToken = default)
{
await _semaphore.WaitAsync(cancellationToken);
return new LockReleaser(_semaphore);
}
public void Dispose()
{
if (_disposed)
{
return;
}
_semaphore.Dispose();
_disposed = true;
}
}
@@ -0,0 +1,6 @@
namespace StevesBot.Worker.Threading;
internal interface IAsyncLock : IDisposable
{
Task<IDisposable> LockAsync(CancellationToken cancellationToken = default);
}
@@ -0,0 +1,23 @@
namespace StevesBot.Worker.Threading;
internal sealed class LockReleaser : IDisposable
{
private readonly SemaphoreSlim _semaphore;
private bool _released;
public LockReleaser(SemaphoreSlim semaphore)
{
_semaphore = semaphore;
}
public void Dispose()
{
if (_released)
{
return;
}
_semaphore.Release();
_released = true;
}
}