chore: continue porting code and adding test coverage
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user