diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index e784069..d5d548f 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -37,6 +37,8 @@ on: permissions: pull-requests: read # allows SonarCloud to decorate PRs with analysis results + contents: read + jobs: sonar-check: @@ -56,8 +58,8 @@ jobs: dotnet tool install --global dotnet-sonarscanner echo "$env:USERPROFILE\.dotnet\tools" >> $env:GITHUB_PATH dotnet sonarscanner begin ` - /k:"ppanchen_NetSdrClient" ` - /o:"ppanchen" ` + /k:"slavik22_ReengineeringCourse" ` + /o:"slavik22" ` /d:sonar.token="${{ secrets.SONAR_TOKEN }}" ` /d:sonar.cs.opencover.reportsPaths="**/coverage.xml" ` /d:sonar.cpd.cs.minimumTokens=40 ` @@ -70,13 +72,17 @@ jobs: run: dotnet restore NetSdrClient.sln - name: Build run: dotnet build NetSdrClient.sln -c Release --no-restore - #- name: Tests with coverage (OpenCover) - # run: | - # dotnet test NetSdrClientAppTests/NetSdrClientAppTests.csproj -c Release --no-build ` - # /p:CollectCoverage=true ` - # /p:CoverletOutput=TestResults/coverage.xml ` - # /p:CoverletOutputFormat=opencover - # shell: pwsh + - name: Tests with coverage (OpenCover) + run: | + dotnet test NetSdrClientAppTests/NetSdrClientAppTests.csproj -c Release --no-build ` + /p:CollectCoverage=true ` + /p:CoverletOutput=TestResults/coverage.xml ` + /p:CoverletOutputFormat=opencover + dotnet test EchoTcpServerTests/EchoTcpServerTests.csproj -c Release --no-build ` + /p:CollectCoverage=true ` + /p:CoverletOutput=TestResults/coverage.xml ` + /p:CoverletOutputFormat=opencover + shell: pwsh # 3) END: SonarScanner - name: SonarScanner End run: dotnet sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" diff --git a/EchoTcpServer/EchoServer.cs b/EchoTcpServer/EchoServer.cs new file mode 100644 index 0000000..672417a --- /dev/null +++ b/EchoTcpServer/EchoServer.cs @@ -0,0 +1,72 @@ +using EchoTcpServer.Networking; + +namespace EchoTcpServer +{ + public class EchoServer + { + private readonly ITcpListener _listener; + private readonly Action _log; + private readonly CancellationTokenSource _cts = new(); + + public EchoServer(ITcpListener listener, Action? log = null) + { + _listener = listener; + _log = log ?? Console.WriteLine; + } + + public async Task StartAsync() + { + _listener.Start(); + _log("Server started."); + + while (!_cts.Token.IsCancellationRequested) + { + try + { + var stream = await _listener.AcceptClientStreamAsync(); + _log("Client connected."); + _ = Task.Run(() => HandleClientAsync(stream, _cts.Token)); + } + catch (ObjectDisposedException) + { + break; + } + } + + _log("Server shutdown."); + } + + public async Task HandleClientAsync(Stream stream, CancellationToken token) + { + using (stream) + { + try + { + byte[] buffer = new byte[8192]; + int bytesRead; + + while (!token.IsCancellationRequested + && (bytesRead = await stream.ReadAsync(buffer.AsMemory(), token)) > 0) + { + await stream.WriteAsync(buffer.AsMemory(0, bytesRead), token); + _log($"Echoed {bytesRead} bytes."); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _log($"Error: {ex.Message}"); + } + } + + _log("Client disconnected."); + } + + public void Stop() + { + _cts.Cancel(); + _listener.Stop(); + _cts.Dispose(); + _log("Server stopped."); + } + } +} diff --git a/EchoTcpServer/EchoServer.csproj b/EchoTcpServer/EchoServer.csproj index 2150e37..46e738e 100644 --- a/EchoTcpServer/EchoServer.csproj +++ b/EchoTcpServer/EchoServer.csproj @@ -5,6 +5,7 @@ net8.0 enable enable + EchoTcpServer diff --git a/EchoTcpServer/Networking/ITcpListener.cs b/EchoTcpServer/Networking/ITcpListener.cs new file mode 100644 index 0000000..32ed125 --- /dev/null +++ b/EchoTcpServer/Networking/ITcpListener.cs @@ -0,0 +1,9 @@ +namespace EchoTcpServer.Networking +{ + public interface ITcpListener + { + void Start(); + void Stop(); + Task AcceptClientStreamAsync(); + } +} diff --git a/EchoTcpServer/Networking/TcpListenerWrapper.cs b/EchoTcpServer/Networking/TcpListenerWrapper.cs new file mode 100644 index 0000000..44a0ab4 --- /dev/null +++ b/EchoTcpServer/Networking/TcpListenerWrapper.cs @@ -0,0 +1,24 @@ +using System.Net; +using System.Net.Sockets; + +namespace EchoTcpServer.Networking +{ + public class TcpListenerWrapper : ITcpListener + { + private readonly TcpListener _listener; + + public TcpListenerWrapper(int port) + { + _listener = new TcpListener(IPAddress.Any, port); + } + + public void Start() => _listener.Start(); + public void Stop() => _listener.Stop(); + + public async Task AcceptClientStreamAsync() + { + var client = await _listener.AcceptTcpClientAsync(); + return client.GetStream(); + } + } +} diff --git a/EchoTcpServer/Program.cs b/EchoTcpServer/Program.cs index 5966c57..bfa9b2a 100644 --- a/EchoTcpServer/Program.cs +++ b/EchoTcpServer/Program.cs @@ -1,173 +1,19 @@ -using System; -using System.Net; -using System.Net.Sockets; -using System.Text; -using System.Threading; -using System.Threading.Tasks; +using EchoTcpServer; +using EchoTcpServer.Networking; -/// -/// This program was designed for test purposes only -/// Not for a review -/// -public class EchoServer -{ - private readonly int _port; - private TcpListener _listener; - private CancellationTokenSource _cancellationTokenSource; - - - public EchoServer(int port) - { - _port = port; - _cancellationTokenSource = new CancellationTokenSource(); - } - - public async Task StartAsync() - { - _listener = new TcpListener(IPAddress.Any, _port); - _listener.Start(); - Console.WriteLine($"Server started on port {_port}."); - - while (!_cancellationTokenSource.Token.IsCancellationRequested) - { - try - { - TcpClient client = await _listener.AcceptTcpClientAsync(); - Console.WriteLine("Client connected."); - - _ = Task.Run(() => HandleClientAsync(client, _cancellationTokenSource.Token)); - } - catch (ObjectDisposedException) - { - // Listener has been closed - break; - } - } - - Console.WriteLine("Server shutdown."); - } - - private async Task HandleClientAsync(TcpClient client, CancellationToken token) - { - using (NetworkStream stream = client.GetStream()) - { - try - { - byte[] buffer = new byte[8192]; - int bytesRead; - - while (!token.IsCancellationRequested && (bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, token)) > 0) - { - // Echo back the received message - await stream.WriteAsync(buffer, 0, bytesRead, token); - Console.WriteLine($"Echoed {bytesRead} bytes to the client."); - } - } - 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 +var server = new EchoServer(new TcpListenerWrapper(5000)); +_ = Task.Run(() => server.StartAsync()); - using (var sender = new UdpTimedSender(host, port)) - { - Console.WriteLine("Press any key to stop sending..."); - sender.StartSending(intervalMilliseconds); +using var sender = new UdpTimedSender("127.0.0.1", 60000); +Console.WriteLine("Press any key to stop sending..."); +sender.StartSending(5000); - Console.WriteLine("Press 'q' to quit..."); - while (Console.ReadKey(intercept: true).Key != ConsoleKey.Q) - { - // Just wait until 'q' is pressed - } - - sender.StopSending(); - server.Stop(); - Console.WriteLine("Sender stopped."); - } - } -} - - -public class UdpTimedSender : IDisposable +Console.WriteLine("Press 'q' to quit..."); +while (Console.ReadKey(intercept: true).Key != ConsoleKey.Q) { - 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; - } + // wait for 'q' key press +} - public void Dispose() - { - StopSending(); - _udpClient.Dispose(); - } -} \ No newline at end of file +sender.StopSending(); +server.Stop(); +Console.WriteLine("Sender stopped."); diff --git a/EchoTcpServer/UdpTimedSender.cs b/EchoTcpServer/UdpTimedSender.cs new file mode 100644 index 0000000..7b154a8 --- /dev/null +++ b/EchoTcpServer/UdpTimedSender.cs @@ -0,0 +1,74 @@ +using System.Net; +using System.Net.Sockets; + +namespace EchoTcpServer +{ + public class UdpTimedSender : IDisposable + { + private readonly string _host; + private readonly int _port; + private readonly UdpClient _udpClient; + private readonly Random _rnd = new(); + private Timer? _timer; + private ushort _sequenceNumber; + + 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); + } + + private void SendMessageCallback(object? state) + { + try + { + byte[] samples = new byte[1024]; + _rnd.NextBytes(samples); + _sequenceNumber++; + + byte[] msg = new byte[] { 0x04, 0x84 } + .Concat(BitConverter.GetBytes(_sequenceNumber)) + .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() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + StopSending(); + _udpClient.Dispose(); + } + } + } +} diff --git a/EchoTcpServerTests/EchoServerTests.cs b/EchoTcpServerTests/EchoServerTests.cs new file mode 100644 index 0000000..02b4363 --- /dev/null +++ b/EchoTcpServerTests/EchoServerTests.cs @@ -0,0 +1,190 @@ +using Moq; +using EchoTcpServer; +using EchoTcpServer.Networking; + +namespace EchoTcpServerTests +{ + [TestFixture] + public class EchoServerTests + { + private Mock _listenerMock = null!; + private List _logs = null!; + private EchoServer _server = null!; + + [SetUp] + public void SetUp() + { + _listenerMock = new Mock(); + _logs = new List(); + _server = new EchoServer(_listenerMock.Object, msg => _logs.Add(msg)); + } + + [Test] + public void Stop_StopsListenerAndLogs() + { + _server.Stop(); + + _listenerMock.Verify(l => l.Stop(), Times.Once); + Assert.That(_logs, Contains.Item("Server stopped.")); + } + + [Test] + public async Task StartAsync_StartsListenerAndLogsShutdown() + { + _listenerMock + .Setup(l => l.AcceptClientStreamAsync()) + .ThrowsAsync(new ObjectDisposedException("listener")); + + await _server.StartAsync(); + + _listenerMock.Verify(l => l.Start(), Times.Once); + Assert.Multiple(() => + { + Assert.That(_logs, Contains.Item("Server started.")); + Assert.That(_logs, Contains.Item("Server shutdown.")); + }); + } + + [Test] + public async Task StartAsync_AcceptsClientAndLogs() + { + _listenerMock.SetupSequence(l => l.AcceptClientStreamAsync()) + .ReturnsAsync(new MemoryStream(new byte[] { 1, 2, 3 })) + .ThrowsAsync(new ObjectDisposedException("listener")); + + await _server.StartAsync(); + + _listenerMock.Verify(l => l.AcceptClientStreamAsync(), Times.Exactly(2)); + Assert.That(_logs, Contains.Item("Client connected.")); + } + + [Test] + public async Task HandleClientAsync_EchoesInputToOutput() + { + var inputData = new byte[] { 0x41, 0x42, 0x43 }; // "ABC" + var inputStream = new MemoryStream(inputData); + var outputStream = new MemoryStream(); + var stream = new DuplexStream(inputStream, outputStream); + + await _server.HandleClientAsync(stream, CancellationToken.None); + + Assert.That(outputStream.ToArray(), Is.EqualTo(inputData)); + } + + [Test] + public async Task HandleClientAsync_LogsEchoedBytes() + { + var stream = new DuplexStream(new MemoryStream(new byte[] { 1, 2 }), new MemoryStream()); + + await _server.HandleClientAsync(stream, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(_logs.Any(m => m.StartsWith("Echoed")), Is.True); + Assert.That(_logs, Contains.Item("Client disconnected.")); + }); + } + + [Test] + public async Task HandleClientAsync_PreCancelledToken_DoesNotRead() + { + var cts = new CancellationTokenSource(); + cts.Cancel(); + + var stream = new DuplexStream(new MemoryStream(), new MemoryStream()); + await _server.HandleClientAsync(stream, cts.Token); + + Assert.That(_logs, Contains.Item("Client disconnected.")); + } + + [Test] + public async Task HandleClientAsync_StreamException_LogsError() + { + await _server.HandleClientAsync(new ErrorStream(), CancellationToken.None); + + Assert.That(_logs.Any(m => m.StartsWith("Error:")), Is.True); + } + + [Test] + public async Task HandleClientAsync_EmptyStream_LogsDisconnected() + { + var emptyStream = new DuplexStream(new MemoryStream(Array.Empty()), new MemoryStream()); + + await _server.HandleClientAsync(emptyStream, CancellationToken.None); + + Assert.That(_logs, Contains.Item("Client disconnected.")); + } + } + + /// + /// Reads from one stream, writes to another — simulates a bidirectional client connection. + /// + internal sealed class DuplexStream : Stream + { + private readonly Stream _read; + private readonly Stream _write; + + public DuplexStream(Stream read, Stream write) + { + _read = read; + _write = write; + } + + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => true; + public override long Length => throw new NotSupportedException(); + public override long Position + { + get => throw new NotSupportedException(); + set => throw new NotSupportedException(); + } + + public override void Flush() => _write.Flush(); + public override int Read(byte[] buffer, int offset, int count) => _read.Read(buffer, offset, count); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) + => _read.ReadAsync(buffer, offset, count, ct); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) + => _read.ReadAsync(buffer, ct); + + public override void Write(byte[] buffer, int offset, int count) => _write.Write(buffer, offset, count); + + public override Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken ct) + => _write.WriteAsync(buffer, offset, count, ct); + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken ct = default) + => _write.WriteAsync(buffer, ct); + + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + } + + /// + /// Stream that throws IOException on every read — simulates a broken connection. + /// + internal sealed class ErrorStream : Stream + { + public override bool CanRead => true; + public override bool CanWrite => true; + public override bool CanSeek => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + + public override void Flush() { } + + public override int Read(byte[] buffer, int offset, int count) => + throw new IOException("Connection reset by peer"); + + public override ValueTask ReadAsync(Memory buffer, CancellationToken ct = default) => + ValueTask.FromException(new IOException("Connection reset by peer")); + + public override Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken ct) => + Task.FromException(new IOException("Connection reset by peer")); + + public override void Write(byte[] buffer, int offset, int count) { } + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + } +} diff --git a/EchoTcpServerTests/EchoTcpServerTests.csproj b/EchoTcpServerTests/EchoTcpServerTests.csproj new file mode 100644 index 0000000..7a2ac1f --- /dev/null +++ b/EchoTcpServerTests/EchoTcpServerTests.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + + + + + + + + + + + + + + + diff --git a/EchoTcpServerTests/UdpTimedSenderTests.cs b/EchoTcpServerTests/UdpTimedSenderTests.cs new file mode 100644 index 0000000..fb73a25 --- /dev/null +++ b/EchoTcpServerTests/UdpTimedSenderTests.cs @@ -0,0 +1,52 @@ +using EchoTcpServer; + +namespace EchoTcpServerTests +{ + [TestFixture] + public class UdpTimedSenderTests + { + [Test] + public void StartSending_WhenAlreadyRunning_ThrowsInvalidOperationException() + { + using var sender = new UdpTimedSender("127.0.0.1", 19999); + sender.StartSending(60_000); + + Assert.Throws(() => sender.StartSending(60_000)); + } + + [Test] + public void StopSending_WithoutStarting_DoesNotThrow() + { + using var sender = new UdpTimedSender("127.0.0.1", 19999); + + Assert.DoesNotThrow(() => sender.StopSending()); + } + + [Test] + public void StopSending_AfterStart_AllowsSecondStart() + { + using var sender = new UdpTimedSender("127.0.0.1", 19999); + sender.StartSending(60_000); + sender.StopSending(); + + Assert.DoesNotThrow(() => sender.StartSending(60_000)); + } + + [Test] + public void Dispose_WithoutStarting_DoesNotThrow() + { + var sender = new UdpTimedSender("127.0.0.1", 19999); + + Assert.DoesNotThrow(() => sender.Dispose()); + } + + [Test] + public void Dispose_WhileSending_DoesNotThrow() + { + var sender = new UdpTimedSender("127.0.0.1", 19999); + sender.StartSending(60_000); + + Assert.DoesNotThrow(() => sender.Dispose()); + } + } +} diff --git a/NetSdrClient.sln b/NetSdrClient.sln index 42431fb..959be4e 100644 --- a/NetSdrClient.sln +++ b/NetSdrClient.sln @@ -9,24 +9,68 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetSdrClientAppTests", "Net EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EchoServer", "EchoTcpServer\EchoServer.csproj", "{9179F2F7-EBEE-4A5D-9FD9-F6E3C18DD263}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EchoTcpServerTests", "EchoTcpServerTests\EchoTcpServerTests.csproj", "{E6BC0BE9-A629-48C5-A08A-8373A21434F5}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EchoTcpServer", "EchoTcpServer", "{DBDFFC83-AD19-3622-0FF4-C4288E16DE63}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {3CA739B1-888F-4B84-8E76-387640E3B3E9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {3CA739B1-888F-4B84-8E76-387640E3B3E9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3CA739B1-888F-4B84-8E76-387640E3B3E9}.Debug|x64.ActiveCfg = Debug|Any CPU + {3CA739B1-888F-4B84-8E76-387640E3B3E9}.Debug|x64.Build.0 = Debug|Any CPU + {3CA739B1-888F-4B84-8E76-387640E3B3E9}.Debug|x86.ActiveCfg = Debug|Any CPU + {3CA739B1-888F-4B84-8E76-387640E3B3E9}.Debug|x86.Build.0 = Debug|Any CPU {3CA739B1-888F-4B84-8E76-387640E3B3E9}.Release|Any CPU.ActiveCfg = Release|Any CPU {3CA739B1-888F-4B84-8E76-387640E3B3E9}.Release|Any CPU.Build.0 = Release|Any CPU + {3CA739B1-888F-4B84-8E76-387640E3B3E9}.Release|x64.ActiveCfg = Release|Any CPU + {3CA739B1-888F-4B84-8E76-387640E3B3E9}.Release|x64.Build.0 = Release|Any CPU + {3CA739B1-888F-4B84-8E76-387640E3B3E9}.Release|x86.ActiveCfg = Release|Any CPU + {3CA739B1-888F-4B84-8E76-387640E3B3E9}.Release|x86.Build.0 = Release|Any CPU {D0155366-89B4-4BA4-90E2-2ECC8C1EB915}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D0155366-89B4-4BA4-90E2-2ECC8C1EB915}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D0155366-89B4-4BA4-90E2-2ECC8C1EB915}.Debug|x64.ActiveCfg = Debug|Any CPU + {D0155366-89B4-4BA4-90E2-2ECC8C1EB915}.Debug|x64.Build.0 = Debug|Any CPU + {D0155366-89B4-4BA4-90E2-2ECC8C1EB915}.Debug|x86.ActiveCfg = Debug|Any CPU + {D0155366-89B4-4BA4-90E2-2ECC8C1EB915}.Debug|x86.Build.0 = Debug|Any CPU {D0155366-89B4-4BA4-90E2-2ECC8C1EB915}.Release|Any CPU.ActiveCfg = Release|Any CPU {D0155366-89B4-4BA4-90E2-2ECC8C1EB915}.Release|Any CPU.Build.0 = Release|Any CPU + {D0155366-89B4-4BA4-90E2-2ECC8C1EB915}.Release|x64.ActiveCfg = Release|Any CPU + {D0155366-89B4-4BA4-90E2-2ECC8C1EB915}.Release|x64.Build.0 = Release|Any CPU + {D0155366-89B4-4BA4-90E2-2ECC8C1EB915}.Release|x86.ActiveCfg = Release|Any CPU + {D0155366-89B4-4BA4-90E2-2ECC8C1EB915}.Release|x86.Build.0 = Release|Any CPU {9179F2F7-EBEE-4A5D-9FD9-F6E3C18DD263}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9179F2F7-EBEE-4A5D-9FD9-F6E3C18DD263}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9179F2F7-EBEE-4A5D-9FD9-F6E3C18DD263}.Debug|x64.ActiveCfg = Debug|Any CPU + {9179F2F7-EBEE-4A5D-9FD9-F6E3C18DD263}.Debug|x64.Build.0 = Debug|Any CPU + {9179F2F7-EBEE-4A5D-9FD9-F6E3C18DD263}.Debug|x86.ActiveCfg = Debug|Any CPU + {9179F2F7-EBEE-4A5D-9FD9-F6E3C18DD263}.Debug|x86.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 + {9179F2F7-EBEE-4A5D-9FD9-F6E3C18DD263}.Release|x64.ActiveCfg = Release|Any CPU + {9179F2F7-EBEE-4A5D-9FD9-F6E3C18DD263}.Release|x64.Build.0 = Release|Any CPU + {9179F2F7-EBEE-4A5D-9FD9-F6E3C18DD263}.Release|x86.ActiveCfg = Release|Any CPU + {9179F2F7-EBEE-4A5D-9FD9-F6E3C18DD263}.Release|x86.Build.0 = Release|Any CPU + {E6BC0BE9-A629-48C5-A08A-8373A21434F5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E6BC0BE9-A629-48C5-A08A-8373A21434F5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E6BC0BE9-A629-48C5-A08A-8373A21434F5}.Debug|x64.ActiveCfg = Debug|Any CPU + {E6BC0BE9-A629-48C5-A08A-8373A21434F5}.Debug|x64.Build.0 = Debug|Any CPU + {E6BC0BE9-A629-48C5-A08A-8373A21434F5}.Debug|x86.ActiveCfg = Debug|Any CPU + {E6BC0BE9-A629-48C5-A08A-8373A21434F5}.Debug|x86.Build.0 = Debug|Any CPU + {E6BC0BE9-A629-48C5-A08A-8373A21434F5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E6BC0BE9-A629-48C5-A08A-8373A21434F5}.Release|Any CPU.Build.0 = Release|Any CPU + {E6BC0BE9-A629-48C5-A08A-8373A21434F5}.Release|x64.ActiveCfg = Release|Any CPU + {E6BC0BE9-A629-48C5-A08A-8373A21434F5}.Release|x64.Build.0 = Release|Any CPU + {E6BC0BE9-A629-48C5-A08A-8373A21434F5}.Release|x86.ActiveCfg = Release|Any CPU + {E6BC0BE9-A629-48C5-A08A-8373A21434F5}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/NetSdrClientApp/Messages/NetSdrMessageHelper.cs b/NetSdrClientApp/Messages/NetSdrMessageHelper.cs index 0d69b4d..5708f8f 100644 --- a/NetSdrClientApp/Messages/NetSdrMessageHelper.cs +++ b/NetSdrClientApp/Messages/NetSdrMessageHelper.cs @@ -1,13 +1,6 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Reflection.PortableExecutable; -using System.Text; -using System.Threading.Tasks; - + namespace NetSdrClientApp.Messages { - //TODO: analyze possible use of [StructLayout] for better performance and readability public static class NetSdrMessageHelper { private const short _maxMessageLength = 8191; @@ -50,20 +43,13 @@ public static byte[] GetDataItemMessage(MsgTypes type, byte[] parameters) private static byte[] GetMessage(MsgTypes type, ControlItemCodes itemCode, byte[] parameters) { - var itemCodeBytes = Array.Empty(); - if (itemCode != ControlItemCodes.None) - { - itemCodeBytes = BitConverter.GetBytes((ushort)itemCode); - } + var itemCodeBytes = itemCode != ControlItemCodes.None + ? BitConverter.GetBytes((ushort)itemCode) + : Array.Empty(); var headerBytes = GetHeader(type, itemCodeBytes.Length + parameters.Length); - List msg = new List(); - msg.AddRange(headerBytes); - msg.AddRange(itemCodeBytes); - msg.AddRange(parameters); - - return msg.ToArray(); + return [.. headerBytes, .. itemCodeBytes, .. parameters]; } public static bool TranslateMessage(byte[] msg, out MsgTypes type, out ControlItemCodes itemCode, out ushort sequenceNumber, out byte[] body) @@ -83,7 +69,7 @@ public static bool TranslateMessage(byte[] msg, out MsgTypes type, out ControlIt msgEnumarable = msgEnumarable.Skip(_msgControlItemLength); msgLength -= _msgControlItemLength; - if (Enum.IsDefined(typeof(ControlItemCodes), value)) + if (Enum.IsDefined(typeof(ControlItemCodes), (int)value)) { itemCode = (ControlItemCodes)value; } @@ -108,23 +94,21 @@ public static bool TranslateMessage(byte[] msg, out MsgTypes type, out ControlIt public static IEnumerable GetSamples(ushort sampleSize, byte[] body) { - sampleSize /= 8; //to bytes - if (sampleSize > 4) - { - throw new ArgumentOutOfRangeException(); - } + int bytesPerSample = sampleSize / 8; + if (bytesPerSample > 4) + throw new ArgumentOutOfRangeException(nameof(sampleSize), sampleSize, "Sample size must be 8, 16, 24, or 32 bits."); - 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 static IEnumerable GetSamplesIterator(int bytesPerSample, byte[] body) + { + var buffer = new byte[4]; + for (int offset = 0; offset + bytesPerSample <= body.Length; offset += bytesPerSample) { - 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); } } diff --git a/NetSdrClientApp/NetSdrClient.cs b/NetSdrClientApp/NetSdrClient.cs index b0a7c05..9378114 100644 --- a/NetSdrClientApp/NetSdrClient.cs +++ b/NetSdrClientApp/NetSdrClient.cs @@ -1,21 +1,13 @@ using NetSdrClientApp.Messages; using NetSdrClientApp.Networking; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading; -using System.Threading.Channels; -using System.Threading.Tasks; using static NetSdrClientApp.Messages.NetSdrMessageHelper; -using static System.Runtime.InteropServices.JavaScript.JSType; namespace NetSdrClientApp { public class NetSdrClient { - private ITcpClient _tcpClient; - private IUdpClient _udpClient; + private readonly ITcpClient _tcpClient; + private readonly IUdpClient _udpClient; public bool IQStarted { get; set; } @@ -66,7 +58,7 @@ public async Task StartIQAsync() return; } -; var iqDataMode = (byte)0x80; + var iqDataMode = (byte)0x80; var start = (byte)0x02; var fifo16bitCaptureMode = (byte)0x01; var n = (byte)1; @@ -114,9 +106,9 @@ public async Task ChangeFrequencyAsync(long hz, int channel) await SendTcpRequest(msg); } - private void _udpClient_MessageReceived(object? sender, byte[] e) + private static void _udpClient_MessageReceived(object? sender, byte[] e) { - NetSdrMessageHelper.TranslateMessage(e, out MsgTypes type, out ControlItemCodes code, out ushort sequenceNum, out byte[] body); + NetSdrMessageHelper.TranslateMessage(e, out _, out _, out _, out byte[] body); var samples = NetSdrMessageHelper.GetSamples(16, body); Console.WriteLine($"Samples recieved: " + body.Select(b => Convert.ToString(b, toBase: 16)).Aggregate((l, r) => $"{l} {r}")); @@ -131,9 +123,9 @@ private void _udpClient_MessageReceived(object? sender, byte[] e) } } - private TaskCompletionSource responseTaskSource; + private TaskCompletionSource? responseTaskSource; - private async Task SendTcpRequest(byte[] msg) + private async Task SendTcpRequest(byte[] msg) { if (!_tcpClient.Connected) { @@ -153,7 +145,6 @@ private async Task SendTcpRequest(byte[] msg) private void _tcpClient_MessageReceived(object? sender, byte[] e) { - //TODO: add Unsolicited messages handling here if (responseTaskSource != null) { responseTaskSource.SetResult(e); diff --git a/NetSdrClientApp/Networking/ITcpClient.cs b/NetSdrClientApp/Networking/ITcpClient.cs index 3470b5d..e779e95 100644 --- a/NetSdrClientApp/Networking/ITcpClient.cs +++ b/NetSdrClientApp/Networking/ITcpClient.cs @@ -1,11 +1,4 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; -using static System.Runtime.InteropServices.JavaScript.JSType; - -namespace NetSdrClientApp.Networking +namespace NetSdrClientApp.Networking { public interface ITcpClient { diff --git a/NetSdrClientApp/Networking/IUdpClient.cs b/NetSdrClientApp/Networking/IUdpClient.cs index 1b9f931..053262b 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(); -} \ No newline at end of file + void StopListening(); + void Exit(); + } +} diff --git a/NetSdrClientApp/Networking/TcpClientWrapper.cs b/NetSdrClientApp/Networking/TcpClientWrapper.cs index 1f37e2e..c005800 100644 --- a/NetSdrClientApp/Networking/TcpClientWrapper.cs +++ b/NetSdrClientApp/Networking/TcpClientWrapper.cs @@ -1,22 +1,15 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Net.Http; -using System.Net.Sockets; +using System.Net.Sockets; using System.Text; -using System.Threading; -using System.Threading.Tasks; namespace NetSdrClientApp.Networking { public class TcpClientWrapper : ITcpClient { - private string _host; - private int _port; + private readonly string _host; + private readonly int _port; private TcpClient? _tcpClient; private NetworkStream? _stream; - private CancellationTokenSource _cts; + private CancellationTokenSource? _cts; public bool Connected => _tcpClient != null && _tcpClient.Connected && _stream != null; @@ -57,6 +50,7 @@ public void Disconnect() if (Connected) { _cts?.Cancel(); + _cts?.Dispose(); _stream?.Close(); _tcpClient?.Close(); @@ -71,26 +65,16 @@ public void Disconnect() } } - public async Task SendMessageAsync(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); - } - else - { - throw new InvalidOperationException("Not connected to a server."); - } - } + public Task SendMessageAsync(byte[] data) => SendBytesAsync(data); + + public Task SendMessageAsync(string str) => SendBytesAsync(Encoding.UTF8.GetBytes(str)); - public async Task SendMessageAsync(string str) + private async Task SendBytesAsync(byte[] data) { - var data = Encoding.UTF8.GetBytes(str); 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); + await _stream.WriteAsync(data.AsMemory()); } else { @@ -106,20 +90,20 @@ private async Task StartListeningAsync() { Console.WriteLine($"Starting listening for incomming messages."); - while (!_cts.Token.IsCancellationRequested) + while (!_cts!.Token.IsCancellationRequested) { byte[] buffer = new byte[8194]; - int bytesRead = await _stream.ReadAsync(buffer, 0, buffer.Length, _cts.Token); + int bytesRead = await _stream.ReadAsync(buffer.AsMemory(), _cts.Token); if (bytesRead > 0) { MessageReceived?.Invoke(this, buffer.AsSpan(0, bytesRead).ToArray()); } } } - catch (OperationCanceledException ex) + catch (OperationCanceledException) { - //empty + // cancellation requested — no action needed } catch (Exception ex) { diff --git a/NetSdrClientApp/Networking/UdpClientWrapper.cs b/NetSdrClientApp/Networking/UdpClientWrapper.cs index 31e0b79..90e371e 100644 --- a/NetSdrClientApp/Networking/UdpClientWrapper.cs +++ b/NetSdrClientApp/Networking/UdpClientWrapper.cs @@ -1,85 +1,69 @@ -using System; 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) + public class UdpClientWrapper : IUdpClient { - _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 - { - _udpClient = new UdpClient(_localEndPoint); - while (!_cts.Token.IsCancellationRequested) - { - UdpReceiveResult result = await _udpClient.ReceiveAsync(_cts.Token); - MessageReceived?.Invoke(this, result.Buffer); - - Console.WriteLine($"Received from {result.RemoteEndPoint}"); - } - } - catch (OperationCanceledException ex) + public UdpClientWrapper(int port) { - //empty + _localEndPoint = new IPEndPoint(IPAddress.Any, port); } - catch (Exception ex) - { - Console.WriteLine($"Error receiving message: {ex.Message}"); - } - } - public void StopListening() - { - try + public async Task StartListeningAsync() { - _cts?.Cancel(); - _udpClient?.Close(); - Console.WriteLine("Stopped listening for UDP messages."); - } - catch (Exception ex) - { - Console.WriteLine($"Error while stopping: {ex.Message}"); - } - } + _cts = new CancellationTokenSource(); + Console.WriteLine("Start listening for UDP messages..."); - public void Exit() - { - try - { - _cts?.Cancel(); - _udpClient?.Close(); - Console.WriteLine("Stopped listening for UDP messages."); + try + { + _udpClient = new UdpClient(_localEndPoint); + while (!_cts.Token.IsCancellationRequested) + { + UdpReceiveResult result = await _udpClient.ReceiveAsync(_cts.Token); + MessageReceived?.Invoke(this, result.Buffer); + + Console.WriteLine($"Received from {result.RemoteEndPoint}"); + } + } + catch (OperationCanceledException) + { + // cancellation requested — no action needed + } + catch (Exception ex) + { + Console.WriteLine($"Error receiving message: {ex.Message}"); + } } - catch (Exception ex) + + public void StopListening() { - Console.WriteLine($"Error while stopping: {ex.Message}"); + try + { + _cts?.Cancel(); + _cts?.Dispose(); + _udpClient?.Close(); + Console.WriteLine("Stopped listening for UDP messages."); + } + catch (Exception ex) + { + Console.WriteLine($"Error while stopping: {ex.Message}"); + } } - } - public override int GetHashCode() - { - var payload = $"{nameof(UdpClientWrapper)}|{_localEndPoint.Address}|{_localEndPoint.Port}"; + public void Exit() => StopListening(); - using var md5 = MD5.Create(); - var hash = md5.ComputeHash(Encoding.UTF8.GetBytes(payload)); + public override int GetHashCode() => + HashCode.Combine(_localEndPoint.Address, _localEndPoint.Port); - return BitConverter.ToInt32(hash, 0); + public override bool Equals(object? obj) => + obj is UdpClientWrapper other && + _localEndPoint.Equals(other._localEndPoint); } -} \ No newline at end of file +} diff --git a/NetSdrClientAppTests/ArchitectureTests.cs b/NetSdrClientAppTests/ArchitectureTests.cs new file mode 100644 index 0000000..9317b93 --- /dev/null +++ b/NetSdrClientAppTests/ArchitectureTests.cs @@ -0,0 +1,69 @@ +using System.Reflection; +using NetArchTest.Rules; +using NetSdrClientApp; +using NetSdrClientApp.Messages; +using NetSdrClientApp.Networking; + +namespace NetSdrClientAppTests; + +public class ArchitectureTests +{ + private static readonly Assembly AppAssembly = typeof(NetSdrClient).Assembly; + + // Rule 1: Messages layer must not reference Networking layer. + // Messages encode the NetSDR protocol and should be transport-agnostic. + [Test] + public void Messages_Should_Not_Depend_On_Networking() + { + var result = Types.InAssembly(AppAssembly) + .That().ResideInNamespace("NetSdrClientApp.Messages") + .ShouldNot().HaveDependencyOn("NetSdrClientApp.Networking") + .GetResult(); + + Assert.That(result.IsSuccessful, Is.True, + "Failing types: " + string.Join(", ", result.FailingTypeNames ?? [])); + } + + // Rule 2: Networking layer must not reference Messages layer. + // TcpClientWrapper / UdpClientWrapper are generic transports — they must + // remain unaware of the NetSDR message format. + [Test] + public void Networking_Should_Not_Depend_On_Messages() + { + var result = Types.InAssembly(AppAssembly) + .That().ResideInNamespace("NetSdrClientApp.Networking") + .ShouldNot().HaveDependencyOn("NetSdrClientApp.Messages") + .GetResult(); + + Assert.That(result.IsSuccessful, Is.True, + "Failing types: " + string.Join(", ", result.FailingTypeNames ?? [])); + } + + // Rule 3: All interfaces in the Networking namespace must start with 'I'. + [Test] + public void Networking_Interfaces_Should_Start_With_I() + { + var result = Types.InAssembly(AppAssembly) + .That().ResideInNamespace("NetSdrClientApp.Networking") + .And().AreInterfaces() + .Should().HaveNameStartingWith("I") + .GetResult(); + + Assert.That(result.IsSuccessful, Is.True, + "Failing types: " + string.Join(", ", result.FailingTypeNames ?? [])); + } + + // Rule 4: All types in the Networking namespace must be public. + // They are part of the public contract injected into NetSdrClient. + [Test] + public void Networking_Types_Should_Be_Public() + { + var result = Types.InAssembly(AppAssembly) + .That().ResideInNamespace("NetSdrClientApp.Networking") + .Should().BePublic() + .GetResult(); + + Assert.That(result.IsSuccessful, Is.True, + "Failing types: " + string.Join(", ", result.FailingTypeNames ?? [])); + } +} diff --git a/NetSdrClientAppTests/NetSdrClientAppTests.csproj b/NetSdrClientAppTests/NetSdrClientAppTests.csproj index 3cbc46a..728eebf 100644 --- a/NetSdrClientAppTests/NetSdrClientAppTests.csproj +++ b/NetSdrClientAppTests/NetSdrClientAppTests.csproj @@ -11,8 +11,13 @@ + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + diff --git a/NetSdrClientAppTests/NetSdrClientTests.cs b/NetSdrClientAppTests/NetSdrClientTests.cs index ad00c4f..2704500 100644 --- a/NetSdrClientAppTests/NetSdrClientTests.cs +++ b/NetSdrClientAppTests/NetSdrClientTests.cs @@ -1,5 +1,6 @@ using Moq; using NetSdrClientApp; +using NetSdrClientApp.Messages; using NetSdrClientApp.Networking; namespace NetSdrClientAppTests; @@ -115,5 +116,60 @@ public async Task StopIQTest() Assert.That(_client.IQStarted, Is.False); } - //TODO: cover the rest of the NetSdrClient code here + [Test] + public async Task StopIQNoConnectionTest() + { + // act — no ConnectAsync, so Connected is false + await _client.StopIQAsync(); + + // assert + _tcpMock.Verify(tcp => tcp.SendMessageAsync(It.IsAny()), Times.Never); + _tcpMock.VerifyGet(tcp => tcp.Connected, Times.AtLeastOnce); + } + + [Test] + public async Task ChangeFrequencyAsyncTest() + { + // arrange + await ConnectAsyncTest(); // 3 setup messages + + // act + await _client.ChangeFrequencyAsync(20_000_000, 1); + + // assert — 4th SendMessageAsync for frequency change + _tcpMock.Verify(tcp => tcp.SendMessageAsync(It.IsAny()), Times.Exactly(4)); + } + + [Test] + public async Task UdpMessageReceived_ValidDataItem_DoesNotThrow() + { + await ConnectAsyncTest(); + await _client.StartIQAsync(); + + // build a valid DataItem2 payload: 2-byte sequence number + sample bytes + var seqNum = BitConverter.GetBytes((ushort)1); + var sampleData = new byte[] { 0x0A, 0x0B, 0x0C, 0x0D }; // 2 × 16-bit samples + var payload = seqNum.Concat(sampleData).ToArray(); + var msg = NetSdrMessageHelper.GetDataItemMessage(NetSdrMessageHelper.MsgTypes.DataItem2, payload); + + Assert.DoesNotThrow(() => + _updMock.Raise(udp => udp.MessageReceived += null, _updMock.Object, msg)); + + // cleanup artefact created by the handler + if (File.Exists("samples.bin")) + File.Delete("samples.bin"); + } + + [Test] + public async Task ConnectAsync_AlreadyConnected_DoesNotReconnect() + { + await ConnectAsyncTest(); // connects and sends 3 setup messages + + // act — call ConnectAsync again while already connected + await _client.ConnectAsync(); + + // assert — still only 3 messages (no second setup) + _tcpMock.Verify(tcp => tcp.Connect(), Times.Once); + _tcpMock.Verify(tcp => tcp.SendMessageAsync(It.IsAny()), Times.Exactly(3)); + } } diff --git a/NetSdrClientAppTests/NetSdrMessageHelperTests.cs b/NetSdrClientAppTests/NetSdrMessageHelperTests.cs index b40fff7..a5bbf1e 100644 --- a/NetSdrClientAppTests/NetSdrMessageHelperTests.cs +++ b/NetSdrClientAppTests/NetSdrMessageHelperTests.cs @@ -30,13 +30,14 @@ public void GetControlItemMessageTest() var actualCode = BitConverter.ToInt16(codeBytes.ToArray()); //Assert - Assert.That(headerBytes.Count(), Is.EqualTo(2)); - Assert.That(msg.Length, Is.EqualTo(actualLength)); - Assert.That(type, Is.EqualTo(actualType)); - - Assert.That(actualCode, Is.EqualTo((short)code)); - - Assert.That(parametersBytes.Count(), Is.EqualTo(parametersLength)); + Assert.Multiple(() => + { + Assert.That(headerBytes, Has.Exactly(2).Items); + Assert.That(msg.Length, Is.EqualTo(actualLength)); + Assert.That(type, Is.EqualTo(actualType)); + Assert.That(actualCode, Is.EqualTo((short)code)); + Assert.That(parametersBytes, Has.Exactly(parametersLength).Items); + }); } [Test] @@ -57,13 +58,57 @@ public void GetDataItemMessageTest() var actualLength = num - ((int)actualType << 13); //Assert - Assert.That(headerBytes.Count(), Is.EqualTo(2)); - Assert.That(msg.Length, Is.EqualTo(actualLength)); - Assert.That(type, Is.EqualTo(actualType)); + Assert.Multiple(() => + { + Assert.That(headerBytes, Has.Exactly(2).Items); + Assert.That(msg.Length, Is.EqualTo(actualLength)); + Assert.That(type, Is.EqualTo(actualType)); + Assert.That(parametersBytes, Has.Exactly(parametersLength).Items); + }); + } + + [Test] + public void TranslateMessageRoundTripTest() + { + // arrange — build a SetControlItem message then parse it back + var parameters = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05 }; + var msg = NetSdrMessageHelper.GetControlItemMessage( + NetSdrMessageHelper.MsgTypes.SetControlItem, + NetSdrMessageHelper.ControlItemCodes.ReceiverFrequency, + parameters); + + // act + bool success = NetSdrMessageHelper.TranslateMessage(msg, + out var type, out var itemCode, out _, out var body); + + // assert + Assert.Multiple(() => + { + Assert.That(success, Is.True); + Assert.That(type, Is.EqualTo(NetSdrMessageHelper.MsgTypes.SetControlItem)); + Assert.That(itemCode, Is.EqualTo(NetSdrMessageHelper.ControlItemCodes.ReceiverFrequency)); + Assert.That(body, Is.EqualTo(parameters)); + }); + } - Assert.That(parametersBytes.Count(), Is.EqualTo(parametersLength)); + [Test] + public void GetSamples16BitReturnsCorrectCountTest() + { + // 6 bytes → 3 samples of 16 bits + var body = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06 }; + + var samples = NetSdrMessageHelper.GetSamples(16, body).ToList(); + + Assert.That(samples, Has.Count.EqualTo(3)); + } + + [Test] + public void GetSamplesThrowsForOversizedBitsTest() + { + // 40 bits / 8 = 5 bytes > 4 → ArgumentOutOfRangeException + Assert.Throws(() => + NetSdrMessageHelper.GetSamples(40, new byte[8]).ToList()); } - //TODO: add more NetSdrMessageHelper tests } } \ No newline at end of file diff --git a/NetSdrClientAppTests/TcpClientWrapperTests.cs b/NetSdrClientAppTests/TcpClientWrapperTests.cs new file mode 100644 index 0000000..11d6c54 --- /dev/null +++ b/NetSdrClientAppTests/TcpClientWrapperTests.cs @@ -0,0 +1,62 @@ +using System.Net; +using System.Net.Sockets; +using NetSdrClientApp.Networking; + +namespace NetSdrClientAppTests; + +[TestFixture] +public class TcpClientWrapperTests +{ + [Test] + public void Disconnect_WhenNotConnected_DoesNotThrow() + { + var wrapper = new TcpClientWrapper("127.0.0.1", 19399); + Assert.DoesNotThrow(() => wrapper.Disconnect()); + } + + [Test] + public async Task SendMessageAsync_WhenNotConnected_ThrowsInvalidOperationException() + { + var wrapper = new TcpClientWrapper("127.0.0.1", 19399); + Assert.ThrowsAsync( + () => wrapper.SendMessageAsync(new byte[] { 1 })); + await Task.CompletedTask; + } + + [Test] + public async Task Connect_SendMessage_ReceivesEchoViaEvent() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + try + { + var wrapper = new TcpClientWrapper("127.0.0.1", port); + var acceptTask = listener.AcceptTcpClientAsync(); + + wrapper.Connect(); + using var server = await acceptTask; + var serverStream = server.GetStream(); + + var received = new TaskCompletionSource(); + wrapper.MessageReceived += (_, data) => received.TrySetResult(data); + + var sendData = new byte[] { 0xAA, 0xBB, 0xCC }; + await wrapper.SendMessageAsync(sendData); + + var buf = new byte[sendData.Length]; + int n = await serverStream.ReadAsync(buf.AsMemory()); + await serverStream.WriteAsync(buf.AsMemory(0, n)); + + var result = await received.Task.WaitAsync(TimeSpan.FromSeconds(2)); + Assert.That(result, Is.EqualTo(sendData)); + + wrapper.Disconnect(); + } + finally + { + listener.Stop(); + } + } +} diff --git a/NetSdrClientAppTests/UdpClientWrapperTests.cs b/NetSdrClientAppTests/UdpClientWrapperTests.cs new file mode 100644 index 0000000..c5ed078 --- /dev/null +++ b/NetSdrClientAppTests/UdpClientWrapperTests.cs @@ -0,0 +1,53 @@ +using NetSdrClientApp.Networking; + +namespace NetSdrClientAppTests; + +[TestFixture] +public class UdpClientWrapperTests +{ + [Test] + public void GetHashCode_IsConsistentAcrossCalls() + { + var wrapper = new UdpClientWrapper(12399); + var hash = wrapper.GetHashCode(); + Assert.That(wrapper.GetHashCode(), Is.EqualTo(hash)); + } + + [Test] + public void GetHashCode_DifferentPorts_ReturnDifferentValues() + { + var w1 = new UdpClientWrapper(12399); + var w2 = new UdpClientWrapper(12400); + Assert.That(w1.GetHashCode(), Is.Not.EqualTo(w2.GetHashCode())); + } + + [Test] + public void Equals_SamePort_ReturnsTrue() + { + var w1 = new UdpClientWrapper(12399); + var w2 = new UdpClientWrapper(12399); + Assert.That(w1, Is.EqualTo(w2)); + } + + [Test] + public void Equals_DifferentPort_ReturnsFalse() + { + var w1 = new UdpClientWrapper(12399); + var w2 = new UdpClientWrapper(12400); + Assert.That(w1, Is.Not.EqualTo(w2)); + } + + [Test] + public void Equals_Null_ReturnsFalse() + { + var wrapper = new UdpClientWrapper(12399); + Assert.That(wrapper, Is.Not.EqualTo(null)); + } + + [Test] + public void Equals_DifferentType_ReturnsFalse() + { + var wrapper = new UdpClientWrapper(12399); + Assert.That(wrapper, Is.Not.EqualTo(new object())); + } +}