diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..446b951e --- /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 e7840696..e538bdf2 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -56,8 +56,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:"YuraKozyyr_NetSdrClient" ` + /o:"yurakozyr" ` /d:sonar.token="${{ secrets.SONAR_TOKEN }}" ` /d:sonar.cs.opencover.reportsPaths="**/coverage.xml" ` /d:sonar.cpd.cs.minimumTokens=40 ` @@ -70,13 +70,14 @@ 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 NetSdrClientApp.ArchTests/NetSdrClientApp.ArchTests.csproj -c Release --no-build + shell: pwsh # 3) END: SonarScanner - name: SonarScanner End run: dotnet sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" diff --git a/NetSdrClient.sln b/NetSdrClient.sln index 42431fb3..3e482753 100644 --- a/NetSdrClient.sln +++ b/NetSdrClient.sln @@ -9,6 +9,8 @@ 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}") = "NetSdrClientApp.ArchTests", "NetSdrClientApp.ArchTests\NetSdrClientApp.ArchTests.csproj", "{28EB108C-651C-4289-9900-3AAE9BB597F2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,6 +29,10 @@ 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 + {28EB108C-651C-4289-9900-3AAE9BB597F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {28EB108C-651C-4289-9900-3AAE9BB597F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {28EB108C-651C-4289-9900-3AAE9BB597F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {28EB108C-651C-4289-9900-3AAE9BB597F2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/NetSdrClientApp.ArchTests/ArchitectureTests.cs b/NetSdrClientApp.ArchTests/ArchitectureTests.cs new file mode 100644 index 00000000..99808861 --- /dev/null +++ b/NetSdrClientApp.ArchTests/ArchitectureTests.cs @@ -0,0 +1,87 @@ +using NetArchTest.Rules; +using NetSdrClientApp; +using NetSdrClientApp.Messages; +using NetSdrClientApp.Networking; +using NUnit.Framework; + +namespace NetSdrClientApp.ArchTests; + +[TestFixture] +public class ArchitectureTests +{ + /// + /// Головна збірка клієнта не повинна залежати від тестового Echo-сервера. + /// (Echo-сервер – лише допоміжний інструмент для тестування) + /// + [Test] + public void NetSdrClientApp_Should_Not_Depend_On_EchoTcpServer() + { + var result = Types + .InAssembly(typeof(NetSdrClient).Assembly) + .ShouldNot() + .HaveDependencyOn("EchoServer") // назва збірки EchoTcpServer + .GetResult(); + + Assert.That(result.IsSuccessful, Is.True, + "NetSdrClientApp не повинен мати залежність від EchoTcpServer (EchoServer)."); + } + + /// + /// Повідомлення (Messages) не мають тягнути за собою залежності від мережевого шару. + /// + [Test] + public void Messages_Should_Not_Depend_On_Networking() + { + var result = Types + .InAssembly(typeof(NetSdrMessageHelper).Assembly) + .That() + .ResideInNamespace("NetSdrClientApp.Messages") + .ShouldNot() + .HaveDependencyOn("NetSdrClientApp.Networking") + .GetResult(); + + Assert.That(result.IsSuccessful, Is.True, + "NetSdrClientApp.Messages не повинен залежати від NetSdrClientApp.Networking."); + } + + /// + /// Навпаки: мережевий шар не має залежати від Messages, + /// щоб уникнути циклічних залежностей. + /// + [Test] + public void Networking_Should_Not_Depend_On_Messages() + { + var result = Types + .InAssembly(typeof(TcpClientWrapper).Assembly) + .That() + .ResideInNamespace("NetSdrClientApp.Networking") + .ShouldNot() + .HaveDependencyOn("NetSdrClientApp.Messages") + .GetResult(); + + Assert.That(result.IsSuccessful, Is.True, + "NetSdrClientApp.Networking не повинен залежати від NetSdrClientApp.Messages."); + } + + /// + /// Усі типи в просторі імен Networking мають бути *Wrapper*-ами. + /// + [Test] + public void Networking_Types_Should_Have_Names_Ending_With_Wrapper() + { + var result = Types + .InAssembly(typeof(TcpClientWrapper).Assembly) + .That() + .ResideInNamespace("NetSdrClientApp.Networking") + .And() + .AreClasses() // 🔹 важливо: тільки класи, без інтерфейсів + .Should() + .HaveNameEndingWith("Wrapper") + .GetResult(); + + Assert.That(result.IsSuccessful, Is.True, + "У NetSdrClientApp.Networking мають бути лише класи, назва яких закінчується на 'Wrapper'."); + } + +} + diff --git a/NetSdrClientApp.ArchTests/GlobalUsings.cs b/NetSdrClientApp.ArchTests/GlobalUsings.cs new file mode 100644 index 00000000..cefced49 --- /dev/null +++ b/NetSdrClientApp.ArchTests/GlobalUsings.cs @@ -0,0 +1 @@ +global using NUnit.Framework; \ No newline at end of file diff --git a/NetSdrClientApp.ArchTests/NetSdrClientApp.ArchTests.csproj b/NetSdrClientApp.ArchTests/NetSdrClientApp.ArchTests.csproj new file mode 100644 index 00000000..fa9e0620 --- /dev/null +++ b/NetSdrClientApp.ArchTests/NetSdrClientApp.ArchTests.csproj @@ -0,0 +1,22 @@ + + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + diff --git a/NetSdrClientApp/NetSdrClient.cs b/NetSdrClientApp/NetSdrClient.cs index b0a7c058..29ac70d1 100644 --- a/NetSdrClientApp/NetSdrClient.cs +++ b/NetSdrClientApp/NetSdrClient.cs @@ -14,8 +14,8 @@ 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 +66,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 +114,14 @@ 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 _, // MsgTypes type не потрібен + out _, // ControlItemCodes code не потрібен + out _, // ushort sequenceNum не потрібен + out var body); // тільки body використовуємо var samples = NetSdrMessageHelper.GetSamples(16, body); Console.WriteLine($"Samples recieved: " + body.Select(b => Convert.ToString(b, toBase: 16)).Aggregate((l, r) => $"{l} {r}")); diff --git a/NetSdrClientApp/NetSdrClientApp.csproj b/NetSdrClientApp/NetSdrClientApp.csproj index 2ac91006..cb3674d8 100644 --- a/NetSdrClientApp/NetSdrClientApp.csproj +++ b/NetSdrClientApp/NetSdrClientApp.csproj @@ -7,8 +7,8 @@ enable - - + + diff --git a/NetSdrClientApp/Networking/TcpClientWrapper.cs b/NetSdrClientApp/Networking/TcpClientWrapper.cs index 1f37e2e5..8ef0df0e 100644 --- a/NetSdrClientApp/Networking/TcpClientWrapper.cs +++ b/NetSdrClientApp/Networking/TcpClientWrapper.cs @@ -1,8 +1,5 @@ using System; -using System.Collections.Generic; -using System.IO; using System.Linq; -using System.Net.Http; using System.Net.Sockets; using System.Text; using System.Threading; @@ -10,13 +7,15 @@ namespace NetSdrClientApp.Networking { - public class TcpClientWrapper : ITcpClient + 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; + private CancellationTokenSource? _cts; + private bool _disposed; public bool Connected => _tcpClient != null && _tcpClient.Connected && _stream != null; @@ -30,52 +29,98 @@ public TcpClientWrapper(string host, int port) public void Connect() { + ThrowIfDisposed(); + if (Connected) { Console.WriteLine($"Already connected to {_host}:{_port}"); return; } + // прибираємо попередню CTS (якщо була) + _cts?.Cancel(); + _cts?.Dispose(); + _cts = new CancellationTokenSource(); + _tcpClient = new TcpClient(); try { - _cts = new CancellationTokenSource(); _tcpClient.Connect(_host, _port); _stream = _tcpClient.GetStream(); Console.WriteLine($"Connected to {_host}:{_port}"); - _ = StartListeningAsync(); + + // запускаємо слухача з токеном цієї CTS + _ = StartListeningAsync(_cts.Token); } catch (Exception ex) { Console.WriteLine($"Failed to connect: {ex.Message}"); + // при фейлі не тримаємо зайві ресурси + _stream?.Dispose(); + _tcpClient?.Dispose(); + _cts?.Dispose(); + + _stream = null; + _tcpClient = null; + _cts = null; } } public void Disconnect() + { + // не даємо викликати метод після Dispose() + ThrowIfDisposed(); + + if (!Connected) + { + Console.WriteLine("No active connection to disconnect."); + return; + } + + try + { + _cts?.Cancel(); + _stream?.Close(); + _tcpClient?.Close(); + } + catch (Exception ex) + { + Console.WriteLine($"Error while disconnecting: {ex.Message}"); + } + finally + { + _stream?.Dispose(); + _tcpClient?.Dispose(); + _cts?.Dispose(); + + _stream = null; + _tcpClient = null; + _cts = null; + } + } + + public Task SendMessageAsync(byte[] data) { - if (Connected) - { - _cts?.Cancel(); - _stream?.Close(); - _tcpClient?.Close(); + return SendMessageInternalAsync(data); + } - _cts = null; - _tcpClient = null; - _stream = null; - Console.WriteLine("Disconnected."); - } - else - { - Console.WriteLine("No active connection to disconnect."); - } + public Task SendMessageAsync(string str) + { + var data = Encoding.UTF8.GetBytes(str); + return SendMessageInternalAsync(data); } - public async Task SendMessageAsync(byte[] data) + private async Task SendMessageInternalAsync(byte[] data) { + ThrowIfDisposed(); + if (Connected && _stream != null && _stream.CanWrite) { - Console.WriteLine($"Message sent: " + data.Select(b => Convert.ToString(b, toBase: 16)).Aggregate((l, r) => $"{l} {r}")); + 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 @@ -84,57 +129,69 @@ public async Task SendMessageAsync(byte[] data) } } - public async Task SendMessageAsync(string str) + private async Task StartListeningAsync(CancellationToken token) { - 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); - } - else + if (_stream == null || !_stream.CanRead) { throw new InvalidOperationException("Not connected to a server."); } - } - private async Task StartListeningAsync() - { - if (Connected && _stream != null && _stream.CanRead) + try { - try - { - Console.WriteLine($"Starting listening for incomming messages."); + Console.WriteLine("Starting listening for incoming messages."); - while (!_cts.Token.IsCancellationRequested) - { - byte[] buffer = new byte[8194]; + var 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()); - } - } - } - catch (OperationCanceledException ex) - { - //empty - } - catch (Exception ex) - { - Console.WriteLine($"Error in listening loop: {ex.Message}"); - } - finally + while (!token.IsCancellationRequested) { - Console.WriteLine("Listener stopped."); + var bytesRead = await _stream.ReadAsync(buffer, 0, buffer.Length, token); + if (bytesRead <= 0) + { + // зʼєднання закрите + break; + } + + var data = buffer.AsSpan(0, bytesRead).ToArray(); + MessageReceived?.Invoke(this, data); } } - else + catch (OperationCanceledException) { - throw new InvalidOperationException("Not connected to a server."); + // очікувано при відключенні + } + catch (Exception ex) + { + Console.WriteLine($"Error in listening loop: {ex.Message}"); + } + finally + { + Console.WriteLine("Listener stopped."); } } - } + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(TcpClientWrapper)); + } + + public void Dispose() + { + if (_disposed) + return; + + _disposed = true; + + _cts?.Cancel(); + + _stream?.Dispose(); + _tcpClient?.Dispose(); + _cts?.Dispose(); + + _stream = null; + _tcpClient = null; + _cts = null; + } + } } + diff --git a/NetSdrClientApp/Networking/UdpClientWrapper.cs b/NetSdrClientApp/Networking/UdpClientWrapper.cs index 31e0b798..613ad58d 100644 --- a/NetSdrClientApp/Networking/UdpClientWrapper.cs +++ b/NetSdrClientApp/Networking/UdpClientWrapper.cs @@ -1,85 +1,145 @@ 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 sealed class UdpClientWrapper : IUdpClient, IDisposable { - _localEndPoint = new IPEndPoint(IPAddress.Any, port); - } + private readonly IPEndPoint _localEndPoint; + private CancellationTokenSource? _cts; + private UdpClient? _udpClient; + private bool _disposed; - public async Task StartListeningAsync() - { - _cts = new CancellationTokenSource(); - Console.WriteLine("Start listening for UDP messages..."); + public event EventHandler? MessageReceived; + + public UdpClientWrapper(int port) + { + _localEndPoint = new IPEndPoint(IPAddress.Any, port); + } - try + public async Task StartListeningAsync() { - _udpClient = new UdpClient(_localEndPoint); - while (!_cts.Token.IsCancellationRequested) + ThrowIfDisposed(); + + if (_cts is not null) + { + Console.WriteLine("UDP listener is already running."); + return; + } + + _cts = new CancellationTokenSource(); + Console.WriteLine("Start listening for UDP messages..."); + + try { - UdpReceiveResult result = await _udpClient.ReceiveAsync(_cts.Token); - MessageReceived?.Invoke(this, result.Buffer); + _udpClient = new UdpClient(_localEndPoint); - Console.WriteLine($"Received from {result.RemoteEndPoint}"); + while (!_cts.IsCancellationRequested) + { + UdpReceiveResult result = await _udpClient.ReceiveAsync(_cts.Token); + MessageReceived?.Invoke(this, result.Buffer); + Console.WriteLine($"Received from {result.RemoteEndPoint}"); + } + } + catch (OperationCanceledException) + { + // очікувано при зупинці + } + catch (Exception ex) + { + Console.WriteLine($"Error receiving message: {ex.Message}"); + } + finally + { + // гарантовано звільняємо ресурси + DisposeUdpResources(); } } - catch (OperationCanceledException ex) + + public void StopListening() { - //empty + StopInternal("StopListening"); } - catch (Exception ex) + + public void Exit() { - Console.WriteLine($"Error receiving message: {ex.Message}"); + StopInternal("Exit"); } - } - public void StopListening() - { - try + private void StopInternal(string reason) { - _cts?.Cancel(); - _udpClient?.Close(); - Console.WriteLine("Stopped listening for UDP messages."); + ThrowIfDisposed(); + + try + { + if (_cts is null && _udpClient is null) + { + Console.WriteLine($"UDP listener already stopped ({reason})."); + return; + } + + _cts?.Cancel(); + } + catch (Exception ex) + { + Console.WriteLine($"Error while cancelling token: {ex.Message}"); + } + finally + { + DisposeUdpResources(); + Console.WriteLine("Stopped listening for UDP messages."); + } } - catch (Exception ex) + + private void DisposeUdpResources() { - Console.WriteLine($"Error while stopping: {ex.Message}"); + _cts?.Dispose(); + _udpClient?.Dispose(); + + _cts = null; + _udpClient = null; } - } - public void Exit() - { - try + // ---- Рівність та хеш-код без MD5 ---- + + public override int GetHashCode() { - _cts?.Cancel(); - _udpClient?.Close(); - Console.WriteLine("Stopped listening for UDP messages."); + // Без криптографії – просто комбінуємо адресу та порт. + return HashCode.Combine(_localEndPoint.Address, _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 Equals(_localEndPoint, other._localEndPoint); } - } - public override int GetHashCode() - { - var payload = $"{nameof(UdpClientWrapper)}|{_localEndPoint.Address}|{_localEndPoint.Port}"; + // ---- Dispose pattern ---- + + private void ThrowIfDisposed() + { + if (_disposed) + throw new ObjectDisposedException(nameof(UdpClientWrapper)); + } + + public void Dispose() + { + if (_disposed) + return; - using var md5 = MD5.Create(); - var hash = md5.ComputeHash(Encoding.UTF8.GetBytes(payload)); + _disposed = true; - return BitConverter.ToInt32(hash, 0); + _cts?.Cancel(); + DisposeUdpResources(); + } } -} \ No newline at end of file +} + diff --git a/NetSdrClientAppTests/NetSdrClientAppTests.csproj b/NetSdrClientAppTests/NetSdrClientAppTests.csproj index 3cbc46af..0a44ab1d 100644 --- a/NetSdrClientAppTests/NetSdrClientAppTests.csproj +++ b/NetSdrClientAppTests/NetSdrClientAppTests.csproj @@ -11,7 +11,11 @@ - + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + diff --git a/NetSdrClientAppTests/NetSdrClientTests.cs b/NetSdrClientAppTests/NetSdrClientTests.cs index ad00c4f8..5b9bfb85 100644 --- a/NetSdrClientAppTests/NetSdrClientTests.cs +++ b/NetSdrClientAppTests/NetSdrClientTests.cs @@ -1,6 +1,11 @@ -using Moq; +using System; +using System.IO; +using System.Threading.Tasks; +using Moq; using NetSdrClientApp; +using NetSdrClientApp.Messages; using NetSdrClientApp.Networking; +using NUnit.Framework; namespace NetSdrClientAppTests; @@ -26,10 +31,11 @@ public void Setup() _tcpMock.Setup(tcp => tcp.Connected).Returns(false); }); - _tcpMock.Setup(tcp => tcp.SendMessageAsync(It.IsAny())).Callback((bytes) => - { - _tcpMock.Raise(tcp => tcp.MessageReceived += null, _tcpMock.Object, bytes); - }); + _tcpMock.Setup(tcp => tcp.SendMessageAsync(It.IsAny())) + .Callback((bytes) => + { + _tcpMock.Raise(tcp => tcp.MessageReceived += null, _tcpMock.Object, bytes); + }); _updMock = new Mock(); @@ -39,10 +45,8 @@ public void Setup() [Test] public async Task ConnectAsyncTest() { - //act await _client.ConnectAsync(); - //assert _tcpMock.Verify(tcp => tcp.Connect(), Times.Once); _tcpMock.Verify(tcp => tcp.SendMessageAsync(It.IsAny()), Times.Exactly(3)); } @@ -50,37 +54,26 @@ public async Task ConnectAsyncTest() [Test] public async Task DisconnectWithNoConnectionTest() { - //act _client.Disconect(); - //assert - //No exception thrown _tcpMock.Verify(tcp => tcp.Disconnect(), Times.Once); } [Test] public async Task DisconnectTest() { - //Arrange await ConnectAsyncTest(); - //act _client.Disconect(); - //assert - //No exception thrown _tcpMock.Verify(tcp => tcp.Disconnect(), Times.Once); } [Test] public async Task StartIQNoConnectionTest() { - - //act await _client.StartIQAsync(); - //assert - //No exception thrown _tcpMock.Verify(tcp => tcp.SendMessageAsync(It.IsAny()), Times.Never); _tcpMock.VerifyGet(tcp => tcp.Connected, Times.AtLeastOnce); } @@ -88,14 +81,10 @@ public async Task StartIQNoConnectionTest() [Test] public async Task StartIQTest() { - //Arrange await ConnectAsyncTest(); - //act await _client.StartIQAsync(); - //assert - //No exception thrown _updMock.Verify(udp => udp.StartListeningAsync(), Times.Once); Assert.That(_client.IQStarted, Is.True); } @@ -103,17 +92,76 @@ public async Task StartIQTest() [Test] public async Task StopIQTest() { - //Arrange await ConnectAsyncTest(); - //act await _client.StopIQAsync(); - //assert - //No exception thrown _updMock.Verify(tcp => tcp.StopListening(), Times.Once); Assert.That(_client.IQStarted, Is.False); } - //TODO: cover the rest of the NetSdrClient code here + [Test] + public async Task StopIQAsync_WhenNotConnected_DoesNotThrow() + { + var tcpMock = new Mock(); + tcpMock.SetupGet(t => t.Connected).Returns(false); + + var udpMock = new Mock(); + + var client = new NetSdrClient(tcpMock.Object, udpMock.Object); + + Assert.DoesNotThrowAsync(async () => await client.StopIQAsync()); + } + + [Test] + public async Task ChangeFrequencyAsync_WhenNotConnected_DoesNotThrowAndUsesSendTcpRequestBranch() + { + var tcpMock = new Mock(); + tcpMock.SetupGet(t => t.Connected).Returns(false); + + var udpMock = new Mock(); + + var client = new NetSdrClient(tcpMock.Object, udpMock.Object); + + Assert.DoesNotThrowAsync(async () => await client.ChangeFrequencyAsync(20_000_000, 1)); + } + + [Test] + public void UdpClient_MessageReceived_WritesSamplesToFile() + { + var tcpMock = new Mock(); + tcpMock.SetupGet(t => t.Connected).Returns(true); + + var udpMock = new Mock(); + + EventHandler? handler = null; + + udpMock.SetupAdd(u => u.MessageReceived += It.IsAny>()) + .Callback>(h => handler += h); + + udpMock.SetupRemove(u => u.MessageReceived -= It.IsAny>()) + .Callback>(h => handler -= h); + + var client = new NetSdrClient(tcpMock.Object, udpMock.Object); + + const string fileName = "samples.bin"; + if (File.Exists(fileName)) + { + File.Delete(fileName); + } + + var body = new byte[] { 0x00, 0x01, 0x00, 0x02 }; // два 16-бітні семпли + var msg = NetSdrMessageHelper.GetDataItemMessage(NetSdrMessageHelper.MsgTypes.DataItem0, body); + + // емулюємо прихід UDP-повідомлення + handler?.Invoke(this, msg); + + Assert.That(File.Exists(fileName), Is.True, "Файл samples.bin мав бути створений"); + + var length = new FileInfo(fileName).Length; + Assert.That(length, Is.GreaterThan(0), "Файл samples.bin має містити дані"); + + File.Delete(fileName); + } } + diff --git a/NetSdrClientAppTests/TcpClientWrapperTests.cs b/NetSdrClientAppTests/TcpClientWrapperTests.cs new file mode 100644 index 00000000..93eb2d4a --- /dev/null +++ b/NetSdrClientAppTests/TcpClientWrapperTests.cs @@ -0,0 +1,344 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NetSdrClientApp.Networking; +using NUnit.Framework; + +namespace NetSdrClientAppTests.Networking +{ + [TestFixture] + public class TcpClientWrapperTests + { + private const string Host = "127.0.0.1"; + + // ----------------- helpers ----------------- + + private static void SetPrivateField(TcpClientWrapper wrapper, string fieldName, T? value) + { + var field = typeof(TcpClientWrapper).GetField(fieldName, + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field, $"Field '{fieldName}' was not found via reflection."); + field!.SetValue(wrapper, value); + } + + private static Task InvokeStartListeningAsync(TcpClientWrapper wrapper, CancellationToken token) + { + var method = typeof(TcpClientWrapper).GetMethod("StartListeningAsync", + BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method, "StartListeningAsync not found via reflection."); + + var taskObj = method!.Invoke(wrapper, new object[] { token }); + return (Task)taskObj!; + } + + /// + /// Стрім, який кидає в Close(), але мовчить у Dispose(). + /// Використовується, щоб зайти в catch у Disconnect. + /// + private sealed class ThrowOnCloseNetworkStream : NetworkStream + { + public ThrowOnCloseNetworkStream(Socket socket) + : base(socket, FileAccess.ReadWrite, ownsSocket: false) + { + } + + public override void Close() + { + throw new InvalidOperationException("Close boom"); + } + + protected override void Dispose(bool disposing) + { + // спеціально нічого не робимо, щоб finally у Disconnect не впав + } + } + + /// + /// Стрім, який кидає в ReadAsync, але мовчить у Dispose(). + /// Використовується, щоб зайти в catch (Exception ex) у StartListeningAsync. + /// + private sealed class ThrowOnReadNetworkStream : NetworkStream + { + public ThrowOnReadNetworkStream(Socket socket) + : base(socket, FileAccess.ReadWrite, ownsSocket: false) + { + } + + public override bool CanRead => true; + + public override Task ReadAsync( + byte[] buffer, int offset, int size, CancellationToken cancellationToken) + { + throw new InvalidOperationException("ReadAsync boom"); + } + + protected override void Dispose(bool disposing) + { + // глушимо, щоб finally у StartListeningAsync завершився без винятку + } + } + + // ----------------- базові сценарії Connect / Send / Disconnect ----------------- + + [Test] + public void Connect_WhenServerIsUnavailable_DoesNotThrow_AndStaysDisconnected() + { + var wrapper = new TcpClientWrapper(Host, 1); + + Assert.DoesNotThrow(() => wrapper.Connect()); + Assert.That(wrapper.Connected, Is.False); + } + + [Test] + public async Task Connect_And_SendMessage_WorksWithLocalListener() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + var wrapper = new TcpClientWrapper(Host, port); + + var acceptTask = listener.AcceptTcpClientAsync(); + + Assert.DoesNotThrow(() => wrapper.Connect()); + Assert.True(wrapper.Connected, "Wrapper should be connected after Connect()."); + + const string message = "hello"; + await wrapper.SendMessageAsync(message); + + using var serverClient = await acceptTask; + using var stream = serverClient.GetStream(); + + var buffer = new byte[message.Length]; + var read = await stream.ReadAsync(buffer, 0, buffer.Length); + var received = Encoding.UTF8.GetString(buffer, 0, read); + + Assert.That(received, Is.EqualTo(message)); + + wrapper.Disconnect(); + listener.Stop(); + } + + [Test] + public void Connect_WhenAlreadyConnected_DoesNotThrow_AndStaysConnected() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + var wrapper = new TcpClientWrapper(Host, port); + var acceptTask = listener.AcceptTcpClientAsync(); + + wrapper.Connect(); + using var _ = acceptTask.Result; // лише для встановлення конекта + + Assert.IsTrue(wrapper.Connected, "First connect failed."); + + Assert.DoesNotThrow(() => wrapper.Connect()); + Assert.IsTrue(wrapper.Connected, "After second Connect() we still must be connected."); + + wrapper.Disconnect(); + listener.Stop(); + } + + [Test] + public void Disconnect_WhenNotConnected_DoesNotThrow() + { + var wrapper = new TcpClientWrapper("localhost", 5555); + + Assert.DoesNotThrow(() => wrapper.Disconnect()); + Assert.False(wrapper.Connected); + } + + [Test] + public async Task Dispose_WhenConnected_ReleasesResourcesAndDisconnects() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + var acceptTask = listener.AcceptTcpClientAsync(); + var wrapper = new TcpClientWrapper(Host, port); + + wrapper.Connect(); + using var _ = await acceptTask; + + Assert.That(wrapper.Connected, Is.True); + + wrapper.Dispose(); + + Assert.That(wrapper.Connected, Is.False); + + listener.Stop(); + } + + [Test] + public void Dispose_CalledTwice_DoesNotThrow() + { + var wrapper = new TcpClientWrapper(Host, 12345); + + wrapper.Dispose(); + Assert.DoesNotThrow(() => wrapper.Dispose()); + } + + [Test] + public void SendMessageAsync_WhenNotConnected_ThrowsInvalidOperationException() + { + var wrapper = new TcpClientWrapper(Host, 65000); + + var ex = Assert.ThrowsAsync( + async () => await wrapper.SendMessageAsync("hello")); + + Assert.That(ex!.Message, Is.EqualTo("Not connected to a server.")); + } + + // ----------------- ThrowIfDisposed ----------------- + + [Test] + public void Connect_AfterDispose_ThrowsObjectDisposedException() + { + var wrapper = new TcpClientWrapper(Host, 12345); + wrapper.Dispose(); + + var ex = Assert.Throws(() => wrapper.Connect()); + Assert.That(ex!.ObjectName, Is.EqualTo(nameof(TcpClientWrapper))); + } + + [Test] + public void Disconnect_AfterDispose_ThrowsObjectDisposedException() + { + var wrapper = new TcpClientWrapper(Host, 12345); + wrapper.Dispose(); + + Assert.Throws(() => wrapper.Disconnect()); + } + + [Test] + public void SendMessageAsync_AfterDispose_ThrowsObjectDisposedException() + { + var wrapper = new TcpClientWrapper(Host, 12345); + wrapper.Dispose(); + + Assert.ThrowsAsync( + async () => await wrapper.SendMessageAsync("hello")); + } + + // ----------------- Connect: стара CTS ----------------- + + [Test] + public void Connect_WhenOldCancellationTokenSourceExists_CancelsAndDisposesIt() + { + var wrapper = new TcpClientWrapper(Host, 1); + + var oldCts = new CancellationTokenSource(); + SetPrivateField(wrapper, "_cts", oldCts); + + Assert.DoesNotThrow(() => wrapper.Connect()); + + Assert.That(oldCts.IsCancellationRequested, Is.True, + "Old CTS must be cancelled when Connect() is called again."); + } + + // ----------------- Disconnect: catch (Exception ex) ----------------- + + [Test] + public async Task Disconnect_WhenStreamCloseThrows_ThrowsInvalidOperationException() + { + // реальне з’єднання, щоб TcpClient.Connected == true + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + var client = new TcpClient(); + var acceptTask = listener.AcceptTcpClientAsync(); + await client.ConnectAsync(Host, port); + using var _ = await acceptTask; // тримаємо серверний бік відкритим + + var wrapper = new TcpClientWrapper("dummy", 0); + + var throwingStream = new ThrowOnCloseNetworkStream(client.Client); + + SetPrivateField(wrapper, "_tcpClient", client); + SetPrivateField(wrapper, "_stream", throwingStream); + SetPrivateField(wrapper, "_cts", new CancellationTokenSource()); + + Assert.That(wrapper.Connected, Is.True, "Precondition: wrapper must be logically connected."); + + var ex = Assert.Throws(() => wrapper.Disconnect()); + Assert.That(ex!.Message, Is.EqualTo("Close boom")); + + listener.Stop(); + } + + // ----------------- StartListeningAsync: успішний сценарій (MessageReceived) ----------------- + + [Test] + public async Task MessageReceived_Event_Raised_WhenServerSendsData() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + var wrapper = new TcpClientWrapper(Host, port); + + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + wrapper.MessageReceived += (sender, data) => + { + tcs.TrySetResult(data); + }; + + var acceptTask = listener.AcceptTcpClientAsync(); + + // Connect() всередині запустить StartListeningAsync(_cts.Token) + wrapper.Connect(); + + using var serverClient = await acceptTask; + using var serverStream = serverClient.GetStream(); + + var msgBytes = Encoding.UTF8.GetBytes("ping"); + await serverStream.WriteAsync(msgBytes, 0, msgBytes.Length); + + var completed = await Task.WhenAny(tcs.Task, Task.Delay(2000)); + Assert.That(completed, Is.SameAs(tcs.Task), "MessageReceived was not raised in time."); + + var received = tcs.Task.Result; + Assert.That(Encoding.UTF8.GetString(received), Is.EqualTo("ping")); + + wrapper.Disconnect(); + listener.Stop(); + } + + // ----------------- StartListeningAsync: catch (Exception ex) ----------------- + + [Test] + public async Task StartListeningAsync_WhenReadAsyncThrows_DoesNotPropagateException() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + + var client = new TcpClient(); + var acceptTask = listener.AcceptTcpClientAsync(); + await client.ConnectAsync(Host, port); + using var _ = await acceptTask; + + var wrapper = new TcpClientWrapper("dummy", 0); + + var throwingStream = new ThrowOnReadNetworkStream(client.Client); + + SetPrivateField(wrapper, "_tcpClient", client); + SetPrivateField(wrapper, "_stream", throwingStream); + + var listenTask = InvokeStartListeningAsync(wrapper, CancellationToken.None); + + await listenTask; + + listener.Stop(); + } + } +} + diff --git a/NetSdrClientAppTests/UdpClientWrapperAdditionalTests.cs b/NetSdrClientAppTests/UdpClientWrapperAdditionalTests.cs new file mode 100644 index 00000000..5f0e0451 --- /dev/null +++ b/NetSdrClientAppTests/UdpClientWrapperAdditionalTests.cs @@ -0,0 +1,40 @@ +using System; +using System.Threading.Tasks; +using NetSdrClientApp.Networking; +using NUnit.Framework; + +namespace NetSdrClientAppTests.Networking; + +[TestFixture] +public class UdpClientWrapperAdditionalTests +{ + [Test] + public void StartListeningAsync_AfterDispose_ThrowsObjectDisposedException() + { + // arrange + var client = new UdpClientWrapper(0); // порт не важливий, ми не дійдемо до створення UdpClient + + client.Dispose(); + + // assert + Assert.ThrowsAsync(async () => + await client.StartListeningAsync()); + } + + [Test] + public void Equals_And_GetHashCode_Work_ForSameAndDifferentPorts() + { + // arrange + var a = new UdpClientWrapper(12345); + var b = new UdpClientWrapper(12345); + var c = new UdpClientWrapper(54321); + + // act + assert + Assert.That(a.Equals(b), Is.True, "Wrapper-и з однаковим портом мають бути рівні"); + Assert.That(a.GetHashCode(), Is.EqualTo(b.GetHashCode()), + "HashCode для однакових параметрів має співпадати"); + + Assert.That(a.Equals(c), Is.False, "Wrapper-и з різними портами не повинні бути рівні"); + } +} + diff --git a/NetSdrClientAppTests/UdpClientWrapperEqualityTests.cs b/NetSdrClientAppTests/UdpClientWrapperEqualityTests.cs new file mode 100644 index 00000000..34a8ed9e --- /dev/null +++ b/NetSdrClientAppTests/UdpClientWrapperEqualityTests.cs @@ -0,0 +1,55 @@ +using System; +using NetSdrClientApp.Networking; +using NUnit.Framework; + +namespace NetSdrClientAppTests.Networking +{ + [TestFixture] + public class UdpClientWrapperEqualityTests + { + [Test] + public void Equals_SameReference_ReturnsTrue() + { + var wrapper = new UdpClientWrapper(60000); + + Assert.That(wrapper.Equals(wrapper), Is.True); + } + + [Test] + public void Equals_DifferentType_ReturnsFalse() + { + var wrapper = new UdpClientWrapper(60000); + + Assert.That(wrapper.Equals("not a wrapper"), Is.False); + } + + [Test] + public void Equals_And_GetHashCode_ForSameEndpoint_AreEqual() + { + var a = new UdpClientWrapper(60000); + var b = new UdpClientWrapper(60000); + + Assert.That(a.Equals(b), Is.True); + Assert.That(a.GetHashCode(), Is.EqualTo(b.GetHashCode())); + } + + [Test] + public void Equals_ForDifferentPorts_ReturnsFalse() + { + var a = new UdpClientWrapper(60000); + var b = new UdpClientWrapper(60001); + + Assert.That(a.Equals(b), Is.False); + } + + [Test] + public void StopWithoutStart_IsNoOp() + { + var wrapper = new UdpClientWrapper(60000); + + Assert.DoesNotThrow(() => wrapper.StopListening()); + Assert.DoesNotThrow(() => wrapper.Exit()); + } + } +} + diff --git a/NetSdrClientAppTests/UdpClientWrapperTests.cs b/NetSdrClientAppTests/UdpClientWrapperTests.cs new file mode 100644 index 00000000..c8c24980 --- /dev/null +++ b/NetSdrClientAppTests/UdpClientWrapperTests.cs @@ -0,0 +1,80 @@ +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using NetSdrClientApp.Networking; +using NUnit.Framework; + +namespace NetSdrClientAppTests.Networking +{ + [TestFixture] + public class UdpClientWrapperTests + { + [Test] + public void StopListening_WhenNotStarted_DoesNotThrow() + { + var wrapper = new UdpClientWrapper(5555); + + Assert.DoesNotThrow(() => wrapper.StopListening()); + } + + [Test] + public void Exit_WhenNotStarted_DoesNotThrow() + { + var wrapper = new UdpClientWrapper(5555); + + Assert.DoesNotThrow(() => wrapper.Exit()); + } + + [Test] + public void GetHashCode_IsStableForSamePort_AndDifferentForOtherPort() + { + var w1 = new UdpClientWrapper(5555); + var w2 = new UdpClientWrapper(5555); + var w3 = new UdpClientWrapper(5556); + + var hash1 = w1.GetHashCode(); + var hash2 = w2.GetHashCode(); + var hash3 = w3.GetHashCode(); + + Assert.AreEqual(hash1, hash2, "Hash must be stable for однакових параметрів."); + Assert.AreNotEqual(hash1, hash3, "Hash має відрізнятися для різних портів."); + } + + [Test] + public async Task StartListeningAsync_RaisesMessageReceived_WhenPacketArrives() + { + // спочатку займаємо вільний UDP порт + int port; + using (var probe = new UdpClient(0)) + { + port = ((IPEndPoint)probe.Client.LocalEndPoint!).Port; + } + + var wrapper = new UdpClientWrapper(port); + + var tcs = new TaskCompletionSource(); + wrapper.MessageReceived += (_, data) => tcs.TrySetResult(data); + + var listeningTask = wrapper.StartListeningAsync(); + + // відправляємо пакет на цей порт + using (var sender = new UdpClient()) + { + var payload = new byte[] { 1, 2, 3 }; + await sender.SendAsync(payload, payload.Length, "127.0.0.1", port); + + // чекаємо максимум 1 секунду + var completed = await Task.WhenAny(tcs.Task, Task.Delay(1000)); + Assert.AreSame(tcs.Task, completed, "Повідомлення не було отримано за таймаут."); + + CollectionAssert.AreEqual(payload, tcs.Task.Result); + } + + wrapper.StopListening(); + + // даємо задачі акуратно завершитися, але не блокуємось вічно + await Task.WhenAny(listeningTask, Task.Delay(1000)); + } + } +} + diff --git a/README.md b/README.md index b3a90294..4a2f6c9e 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,12 @@ # Лабораторні з реінжинірингу (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=YuraKozyyr_NetSdrClient&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=YuraKozyyr_NetSdrClient) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=YuraKozyyr_NetSdrClient&metric=coverage)](https://sonarcloud.io/summary/new_code?id=YuraKozyyr_NetSdrClient) +[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=YuraKozyyr_NetSdrClient&metric=bugs)](https://sonarcloud.io/summary/new_code?id=YuraKozyyr_NetSdrClient) +[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=YuraKozyyr_NetSdrClient&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=YuraKozyyr_NetSdrClient) +[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=YuraKozyyr_NetSdrClient&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=YuraKozyyr_NetSdrClient) +[![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=YuraKozyyr_NetSdrClient&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=YuraKozyyr_NetSdrClient) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=YuraKozyyr_NetSdrClient&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=YuraKozyyr_NetSdrClient) +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=YuraKozyyr_NetSdrClient&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=YuraKozyyr_NetSdrClient) Цей репозиторій використовується для курсу **реінжиніринг ПЗ**. @@ -276,3 +276,7 @@ updates: Обмежити умову запуску Sonar: тільки PR **або** `refs/heads/master`. - **PR зелений, push червоний** Перевірити **New Code Definition** (Number of days або Previous version) і довести покриття/дублікації на “new code”. + + + +