diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..03eba18 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: + - package-ecosystem: "nuget" + directory: "/" + schedule: + interval: "weekly" + open-pull-requests-limit: 10 diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index e784069..60c6acb 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -1,31 +1,3 @@ -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -# This workflow helps you trigger a SonarCloud analysis of your code and populates -# GitHub Code Scanning alerts with the vulnerabilities found. -# Free for open source project. - -# 1. Login to SonarCloud.io using your GitHub account - -# 2. Import your project on SonarCloud -# * Add your GitHub organization first, then add your repository as a new project. -# * Please note that many languages are eligible for automatic analysis, -# which means that the analysis will start automatically without the need to set up GitHub Actions. -# * This behavior can be changed in Administration > Analysis Method. -# -# 3. Follow the SonarCloud in-product tutorial -# * a. Copy/paste the Project Key and the Organization Key into the args parameter below -# (You'll find this information in SonarCloud. Click on "Information" at the bottom left) -# -# * b. Generate a new token and add it to your Github repository's secrets using the name SONAR_TOKEN -# (On SonarCloud, click on your avatar on top-right > My account > Security -# or go directly to https://sonarcloud.io/account/security/) - -# Feel free to take a look at our documentation (https://docs.sonarcloud.io/getting-started/github/) -# or reach out to our community forum if you need some help (https://community.sonarsource.com/c/help/sc/9) - name: SonarCloud analysis on: @@ -36,14 +8,16 @@ on: workflow_dispatch: permissions: - pull-requests: read # allows SonarCloud to decorate PRs with analysis results + pull-requests: read jobs: sonar-check: name: Sonar Check - runs-on: windows-latest # безпечно для будь-яких .NET проектів + runs-on: windows-latest steps: - uses: actions/checkout@v4 + env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true with: { fetch-depth: 0 } - uses: actions/setup-dotnet@v4 @@ -56,27 +30,39 @@ jobs: dotnet tool install --global dotnet-sonarscanner echo "$env:USERPROFILE\.dotnet\tools" >> $env:GITHUB_PATH dotnet sonarscanner begin ` - /k:"ppanchen_NetSdrClient" ` - /o:"ppanchen" ` + /k:"MinTins_ReengineeringCourse" ` + /o:"roman-flakei" ` /d:sonar.token="${{ secrets.SONAR_TOKEN }}" ` /d:sonar.cs.opencover.reportsPaths="**/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=true + /d:sonar.qualitygate.wait=false shell: pwsh + # 2) BUILD & TEST - name: Restore 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 NetSdrClientAppTests with coverage + 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 EchoServerTests with coverage + run: | + dotnet test EchoServerTests/EchoServerTests.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/EchoServerTests/EchoServerTests.cs b/EchoServerTests/EchoServerTests.cs new file mode 100644 index 0000000..e889ede --- /dev/null +++ b/EchoServerTests/EchoServerTests.cs @@ -0,0 +1,166 @@ +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using EchoTcpServer; +using Moq; +using NUnit.Framework; + +namespace EchoServerTests +{ + public class EchoServerTests + { + private Mock _listenerMock = null!; + private EchoServer _server = null!; + + [SetUp] + public void Setup() + { + _listenerMock = new Mock(); + _server = new EchoServer(_listenerMock.Object); + } + + // --------------------------------------------------------------- + // StartAsync — запускає listener і зупиняється при скасуванні + // --------------------------------------------------------------- + + [Test] + public async Task StartAsync_CallsListenerStart() + { + // Arrange: AcceptTcpClientAsync кидає ObjectDisposedException одразу + // щоб вийти з циклу без реального сокета + _listenerMock + .Setup(l => l.AcceptTcpClientAsync()) + .ThrowsAsync(new ObjectDisposedException("listener")); + + // Act + await _server.StartAsync(); + + // Assert + _listenerMock.Verify(l => l.Start(), Times.Once); + } + + [Test] + public async Task StartAsync_ExitsLoop_OnObjectDisposedException() + { + // Arrange + _listenerMock + .Setup(l => l.AcceptTcpClientAsync()) + .ThrowsAsync(new ObjectDisposedException("listener")); + + // Act — не має зависнути + var task = _server.StartAsync(); + await Task.WhenAny(task, Task.Delay(2000)); + + // Assert — метод завершився без timeout + Assert.That(task.IsCompleted, Is.True); + } + + // --------------------------------------------------------------- + // Stop — скасовує токен і зупиняє listener + // --------------------------------------------------------------- + + [Test] + public void Stop_CallsListenerStop() + { + // Act + _server.Stop(); + + // Assert + _listenerMock.Verify(l => l.Stop(), Times.Once); + } + + [Test] + public void Stop_CanBeCalledWithoutStart() + { + // Act & Assert — не кидає виняток + Assert.DoesNotThrow(() => _server.Stop()); + } + + // --------------------------------------------------------------- + // HandleClientAsync — основна логіка echo + // --------------------------------------------------------------- + + [Test] + public async Task HandleClientAsync_EchoesDataBack() + { + // Arrange: два TcpClient спілкуються через loopback + using var serverSocket = new TcpListener(System.Net.IPAddress.Loopback, 0); + serverSocket.Start(); + int port = ((System.Net.IPEndPoint)serverSocket.LocalEndpoint).Port; + + using var clientTcp = new TcpClient(); + await clientTcp.ConnectAsync(System.Net.IPAddress.Loopback, port); + using var serverClient = await serverSocket.AcceptTcpClientAsync(); + serverSocket.Stop(); + + var cts = new CancellationTokenSource(); + var handleTask = _server.HandleClientAsync(serverClient, cts.Token); + + // Act: надіслати дані і прочитати echo + byte[] sent = new byte[] { 0x01, 0x02, 0x03, 0x04 }; + var clientStream = clientTcp.GetStream(); + await clientStream.WriteAsync(sent, 0, sent.Length); + + byte[] received = new byte[sent.Length]; + int bytesRead = await clientStream.ReadAsync(received, 0, received.Length); + + // Assert + Assert.That(bytesRead, Is.EqualTo(sent.Length)); + Assert.That(received, Is.EqualTo(sent)); + + // Cleanup + cts.Cancel(); + clientTcp.Close(); + await Task.WhenAny(handleTask, Task.Delay(1000)); + } + + [Test] + public async Task HandleClientAsync_StopsOnCancellation() + { + // Arrange + using var serverSocket = new TcpListener(System.Net.IPAddress.Loopback, 0); + serverSocket.Start(); + int port = ((System.Net.IPEndPoint)serverSocket.LocalEndpoint).Port; + + using var clientTcp = new TcpClient(); + await clientTcp.ConnectAsync(System.Net.IPAddress.Loopback, port); + using var serverClient = await serverSocket.AcceptTcpClientAsync(); + serverSocket.Stop(); + + var cts = new CancellationTokenSource(); + + // Act: скасувати токен одразу + cts.Cancel(); + var handleTask = _server.HandleClientAsync(serverClient, cts.Token); + await Task.WhenAny(handleTask, Task.Delay(2000)); + + // Assert — завершилось без зависання + Assert.That(handleTask.IsCompleted, Is.True); + } + + [Test] + public async Task HandleClientAsync_ClosesClientOnCompletion() + { + // Arrange + using var serverSocket = new TcpListener(System.Net.IPAddress.Loopback, 0); + serverSocket.Start(); + int port = ((System.Net.IPEndPoint)serverSocket.LocalEndpoint).Port; + + using var clientTcp = new TcpClient(); + await clientTcp.ConnectAsync(System.Net.IPAddress.Loopback, port); + using var serverClient = await serverSocket.AcceptTcpClientAsync(); + serverSocket.Stop(); + + var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act + await Task.WhenAny( + _server.HandleClientAsync(serverClient, cts.Token), + Task.Delay(2000)); + + // Assert — після завершення клієнт закритий + Assert.That(serverClient.Connected, Is.False); + } + } +} diff --git a/EchoServerTests/EchoServerTests.csproj b/EchoServerTests/EchoServerTests.csproj new file mode 100644 index 0000000..ccc8ded --- /dev/null +++ b/EchoServerTests/EchoServerTests.csproj @@ -0,0 +1,32 @@ + + + + net10.0 + latest + enable + enable + false + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + diff --git a/EchoTcpServer/EchoServer.cs b/EchoTcpServer/EchoServer.cs new file mode 100644 index 0000000..27680f4 --- /dev/null +++ b/EchoTcpServer/EchoServer.cs @@ -0,0 +1,81 @@ +using System; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace EchoTcpServer +{ + /// + /// TCP echo server. Accepts connections and echoes back all received bytes. + /// Refactored for testability: accepts ITcpListener so real socket can be + /// replaced with a mock in unit tests. + /// + public class EchoServer + { + private readonly ITcpListener _listener; + private readonly CancellationTokenSource _cts; + + public EchoServer(ITcpListener listener) + { + _listener = listener; + _cts = new CancellationTokenSource(); + } + + public async Task StartAsync() + { + _listener.Start(); + Console.WriteLine("Server started."); + + while (!_cts.Token.IsCancellationRequested) + { + try + { + TcpClient client = await _listener.AcceptTcpClientAsync(); + Console.WriteLine("Client connected."); + _ = Task.Run(() => HandleClientAsync(client, _cts.Token)); + } + catch (ObjectDisposedException) + { + break; + } + } + + Console.WriteLine("Server shutdown."); + } + + // internal — accessible from EchoServerTests via InternalsVisibleTo + internal 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) + { + await stream.WriteAsync(buffer, 0, bytesRead, token); + Console.WriteLine($"Echoed {bytesRead} bytes to the client."); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Console.WriteLine($"Error: {ex.Message}"); + } + finally + { + client.Close(); + Console.WriteLine("Client disconnected."); + } + } + + public void Stop() + { + _cts.Cancel(); + _listener.Stop(); + _cts.Dispose(); + Console.WriteLine("Server stopped."); + } + } +} diff --git a/EchoTcpServer/EchoServer.csproj b/EchoTcpServer/EchoServer.csproj index 2150e37..b2db3a4 100644 --- a/EchoTcpServer/EchoServer.csproj +++ b/EchoTcpServer/EchoServer.csproj @@ -1,10 +1,16 @@ - + Exe - net8.0 + net10.0 enable enable + + + <_Parameter1>EchoServerTests + + + diff --git a/EchoTcpServer/ITcpListener.cs b/EchoTcpServer/ITcpListener.cs new file mode 100644 index 0000000..eb5a063 --- /dev/null +++ b/EchoTcpServer/ITcpListener.cs @@ -0,0 +1,15 @@ +using System.Net.Sockets; +using System.Threading.Tasks; + +namespace EchoTcpServer +{ + /// + /// Abstraction over TcpListener to enable unit testing without real sockets. + /// + public interface ITcpListener + { + void Start(); + void Stop(); + Task AcceptTcpClientAsync(); + } +} diff --git a/EchoTcpServer/Program.cs b/EchoTcpServer/Program.cs index 5966c57..ad1c090 100644 --- a/EchoTcpServer/Program.cs +++ b/EchoTcpServer/Program.cs @@ -1,173 +1,28 @@ -using System; -using System.Net; -using System.Net.Sockets; -using System.Text; -using System.Threading; +using System; using System.Threading.Tasks; +using EchoTcpServer; /// -/// This program was designed for test purposes only -/// Not for a review +/// Entry point. Not for review — infrastructure/composition root only. /// -public class EchoServer +internal class Program { - 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); + var listener = new TcpListenerWrapper(5000); + var server = new EchoServer(listener); - // 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); + 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 - } + Console.WriteLine("Press 'q' to quit..."); + while (Console.ReadKey(intercept: true).Key != ConsoleKey.Q) { } - sender.StopSending(); - server.Stop(); - Console.WriteLine("Sender stopped."); - } + sender.StopSending(); + server.Stop(); + Console.WriteLine("Sender stopped."); } } - - -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/EchoTcpServer/TcpListenerWrapper.cs b/EchoTcpServer/TcpListenerWrapper.cs new file mode 100644 index 0000000..45328d8 --- /dev/null +++ b/EchoTcpServer/TcpListenerWrapper.cs @@ -0,0 +1,25 @@ +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Net.Sockets; + +namespace EchoTcpServer +{ + /// + /// Production wrapper over System.Net.Sockets.TcpListener. + /// Excluded from coverage — contains real socket I/O. + /// + [ExcludeFromCodeCoverage] + 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 Task AcceptTcpClientAsync() => _listener.AcceptTcpClientAsync(); + } +} diff --git a/EchoTcpServer/UdpTimedSender.cs b/EchoTcpServer/UdpTimedSender.cs new file mode 100644 index 0000000..e455a1b --- /dev/null +++ b/EchoTcpServer/UdpTimedSender.cs @@ -0,0 +1,68 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; + +namespace EchoTcpServer +{ + public class UdpTimedSender : IDisposable + { + private readonly string _host; + private readonly int _port; + private readonly UdpClient _udpClient; + private Timer? _timer; + private ushort _counter = 0; + + 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 + { + Random rnd = new Random(); + byte[] samples = new byte[1024]; + rnd.NextBytes(samples); + _counter++; + + byte[] msg = new byte[] { 0x04, 0x84 } + .Concat(BitConverter.GetBytes(_counter)) + .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(); + } + } +} diff --git a/NetSdrArchTests/ArchitectureTests.cs b/NetSdrArchTests/ArchitectureTests.cs new file mode 100644 index 0000000..9a102a2 --- /dev/null +++ b/NetSdrArchTests/ArchitectureTests.cs @@ -0,0 +1,109 @@ +using NetArchTest.Rules; +using NUnit.Framework; + +namespace NetSdrArchTests +{ + /// + /// Lab 5 — Architectural rules using NetArchTest. + /// These tests enforce dependency constraints between layers. + /// + /// Note on project structure: + /// - TcpClientWrapper, ITcpClient → namespace NetSdrClientApp.Networking + /// - UdpClientWrapper, IUdpClient → global namespace (no namespace declaration) + /// Rules are written to reflect this actual structure. + /// + public class ArchitectureTests + { + private static readonly System.Reflection.Assembly AppAssembly = + typeof(NetSdrClientApp.Networking.ITcpClient).Assembly; + + // --------------------------------------------------------------- + // Rule 1: Networking namespace must NOT depend on Messages namespace + // Rationale: networking is infrastructure — must not know about + // domain-level message construction + // --------------------------------------------------------------- + [Test] + public void Networking_ShouldNotDependOn_Messages() + { + var result = Types + .InAssembly(AppAssembly) + .That() + .ResideInNamespace("NetSdrClientApp.Networking") + .ShouldNot() + .HaveDependencyOn("NetSdrClientApp.Messages") + .GetResult(); + + Assert.That(result.IsSuccessful, Is.True, + "Networking layer must not depend on Messages layer. " + + "Failing types: " + string.Join(", ", result.FailingTypeNames ?? [])); + } + + // --------------------------------------------------------------- + // Rule 2: Messages namespace must NOT depend on Networking namespace + // Rationale: message building is pure domain logic, + // independent of transport implementation + // --------------------------------------------------------------- + [Test] + public void Messages_ShouldNotDependOn_Networking() + { + var result = Types + .InAssembly(AppAssembly) + .That() + .ResideInNamespace("NetSdrClientApp.Messages") + .ShouldNot() + .HaveDependencyOn("NetSdrClientApp.Networking") + .GetResult(); + + Assert.That(result.IsSuccessful, Is.True, + "Messages layer must not depend on Networking layer. " + + "Failing types: " + string.Join(", ", result.FailingTypeNames ?? [])); + } + + // --------------------------------------------------------------- + // Rule 3: TcpClientWrapper must implement ITcpClient + // Rationale: TCP networking component must be abstracted + // behind interface for testability and substitutability + // Note: scoped to ITcpClient only because UdpClientWrapper/IUdpClient + // reside in global namespace (outside NetSdrClientApp.Networking) + // --------------------------------------------------------------- + [Test] + public void TcpClientWrapper_ShouldImplement_ITcpClient() + { + var result = Types + .InAssembly(AppAssembly) + .That() + .ResideInNamespace("NetSdrClientApp.Networking") + .And() + .AreClasses() + .Should() + .ImplementInterface(typeof(NetSdrClientApp.Networking.ITcpClient)) + .GetResult(); + + Assert.That(result.IsSuccessful, Is.True, + "All concrete classes in NetSdrClientApp.Networking must implement ITcpClient. " + + "Failing types: " + string.Join(", ", result.FailingTypeNames ?? [])); + } + + // --------------------------------------------------------------- + // Rule 4: NetSdrClient orchestrator must reside in root namespace + // Rationale: the top-level client must not leak into sub-layers + // --------------------------------------------------------------- + [Test] + public void NetSdrClient_ShouldResideIn_RootNamespace() + { + var result = Types + .InAssembly(AppAssembly) + .That() + .HaveNameStartingWith("NetSdrClient") + .And() + .AreClasses() + .Should() + .ResideInNamespace("NetSdrClientApp") + .GetResult(); + + Assert.That(result.IsSuccessful, Is.True, + "NetSdrClient class must reside in root NetSdrClientApp namespace. " + + "Failing types: " + string.Join(", ", result.FailingTypeNames ?? [])); + } + } +} diff --git a/NetSdrArchTests/NetSdrArchTests.csproj b/NetSdrArchTests/NetSdrArchTests.csproj new file mode 100644 index 0000000..e26d559 --- /dev/null +++ b/NetSdrArchTests/NetSdrArchTests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/NetSdrClient.sln b/NetSdrClient.sln index 42431fb..4e7b69f 100644 --- a/NetSdrClient.sln +++ b/NetSdrClient.sln @@ -9,24 +9,82 @@ 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}") = "NetSdrArchTests", "NetSdrArchTests\NetSdrArchTests.csproj", "{23A9A89D-B1B6-495C-B709-3E3C88B0B957}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EchoServerTests", "EchoServerTests\EchoServerTests.csproj", "{DEE234DC-88DE-4FBF-A293-436925C3CF49}" +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 + {23A9A89D-B1B6-495C-B709-3E3C88B0B957}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {23A9A89D-B1B6-495C-B709-3E3C88B0B957}.Debug|Any CPU.Build.0 = Debug|Any CPU + {23A9A89D-B1B6-495C-B709-3E3C88B0B957}.Debug|x64.ActiveCfg = Debug|Any CPU + {23A9A89D-B1B6-495C-B709-3E3C88B0B957}.Debug|x64.Build.0 = Debug|Any CPU + {23A9A89D-B1B6-495C-B709-3E3C88B0B957}.Debug|x86.ActiveCfg = Debug|Any CPU + {23A9A89D-B1B6-495C-B709-3E3C88B0B957}.Debug|x86.Build.0 = Debug|Any CPU + {23A9A89D-B1B6-495C-B709-3E3C88B0B957}.Release|Any CPU.ActiveCfg = Release|Any CPU + {23A9A89D-B1B6-495C-B709-3E3C88B0B957}.Release|Any CPU.Build.0 = Release|Any CPU + {23A9A89D-B1B6-495C-B709-3E3C88B0B957}.Release|x64.ActiveCfg = Release|Any CPU + {23A9A89D-B1B6-495C-B709-3E3C88B0B957}.Release|x64.Build.0 = Release|Any CPU + {23A9A89D-B1B6-495C-B709-3E3C88B0B957}.Release|x86.ActiveCfg = Release|Any CPU + {23A9A89D-B1B6-495C-B709-3E3C88B0B957}.Release|x86.Build.0 = Release|Any CPU + {DEE234DC-88DE-4FBF-A293-436925C3CF49}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DEE234DC-88DE-4FBF-A293-436925C3CF49}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DEE234DC-88DE-4FBF-A293-436925C3CF49}.Debug|x64.ActiveCfg = Debug|Any CPU + {DEE234DC-88DE-4FBF-A293-436925C3CF49}.Debug|x64.Build.0 = Debug|Any CPU + {DEE234DC-88DE-4FBF-A293-436925C3CF49}.Debug|x86.ActiveCfg = Debug|Any CPU + {DEE234DC-88DE-4FBF-A293-436925C3CF49}.Debug|x86.Build.0 = Debug|Any CPU + {DEE234DC-88DE-4FBF-A293-436925C3CF49}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DEE234DC-88DE-4FBF-A293-436925C3CF49}.Release|Any CPU.Build.0 = Release|Any CPU + {DEE234DC-88DE-4FBF-A293-436925C3CF49}.Release|x64.ActiveCfg = Release|Any CPU + {DEE234DC-88DE-4FBF-A293-436925C3CF49}.Release|x64.Build.0 = Release|Any CPU + {DEE234DC-88DE-4FBF-A293-436925C3CF49}.Release|x86.ActiveCfg = Release|Any CPU + {DEE234DC-88DE-4FBF-A293-436925C3CF49}.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..1fb0248 100644 --- a/NetSdrClientApp/Messages/NetSdrMessageHelper.cs +++ b/NetSdrClientApp/Messages/NetSdrMessageHelper.cs @@ -83,7 +83,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; } diff --git a/NetSdrClientApp/NetSdrClient.cs b/NetSdrClientApp/NetSdrClient.cs index b0a7c05..f0a3cc0 100644 --- a/NetSdrClientApp/NetSdrClient.cs +++ b/NetSdrClientApp/NetSdrClient.cs @@ -1,4 +1,4 @@ -using NetSdrClientApp.Messages; +using NetSdrClientApp.Messages; using NetSdrClientApp.Networking; using System; using System.Collections.Generic; @@ -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; } @@ -60,13 +60,9 @@ public void Disconect() public async Task StartIQAsync() { - if (!_tcpClient.Connected) - { - Console.WriteLine("No active connection."); - return; - } + if (!EnsureConnected()) return; -; var iqDataMode = (byte)0x80; + var iqDataMode = (byte)0x80; var start = (byte)0x02; var fifo16bitCaptureMode = (byte)0x01; var n = (byte)1; @@ -84,11 +80,7 @@ public async Task StartIQAsync() public async Task StopIQAsync() { - if (!_tcpClient.Connected) - { - Console.WriteLine("No active connection."); - return; - } + if (!EnsureConnected()) return; var stop = (byte)0x01; @@ -114,9 +106,17 @@ public async Task ChangeFrequencyAsync(long hz, int channel) await SendTcpRequest(msg); } + + private bool EnsureConnected() + { + if (_tcpClient.Connected) return true; + Console.WriteLine("No active connection."); + return false; + } + private 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}")); @@ -135,11 +135,7 @@ private void _udpClient_MessageReceived(object? sender, byte[] e) private async Task SendTcpRequest(byte[] msg) { - if (!_tcpClient.Connected) - { - Console.WriteLine("No active connection."); - return null; - } + if (!EnsureConnected()) return null; responseTaskSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var responseTask = responseTaskSource.Task; diff --git a/NetSdrClientApp/NetSdrClientApp.csproj b/NetSdrClientApp/NetSdrClientApp.csproj index 2ac9100..5393e74 100644 --- a/NetSdrClientApp/NetSdrClientApp.csproj +++ b/NetSdrClientApp/NetSdrClientApp.csproj @@ -2,13 +2,13 @@ Exe - net8.0 + net10.0 enable enable - - + + diff --git a/NetSdrClientApp/Networking/TcpClientWrapper.cs b/NetSdrClientApp/Networking/TcpClientWrapper.cs index 1f37e2e..71eb77f 100644 --- a/NetSdrClientApp/Networking/TcpClientWrapper.cs +++ b/NetSdrClientApp/Networking/TcpClientWrapper.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.IO; using System.Linq; @@ -7,13 +7,15 @@ using System.Text; using System.Threading; using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; namespace NetSdrClientApp.Networking { + [ExcludeFromCodeCoverage] 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; @@ -87,15 +89,7 @@ public async Task SendMessageAsync(byte[] data) public async Task SendMessageAsync(string str) { 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 - { - throw new InvalidOperationException("Not connected to a server."); - } + await SendMessageAsync(data); } private async Task StartListeningAsync() @@ -117,7 +111,7 @@ private async Task StartListeningAsync() } } } - catch (OperationCanceledException ex) + catch (OperationCanceledException) { //empty } diff --git a/NetSdrClientApp/Networking/UdpClientWrapper.cs b/NetSdrClientApp/Networking/UdpClientWrapper.cs index 31e0b79..a32c4b7 100644 --- a/NetSdrClientApp/Networking/UdpClientWrapper.cs +++ b/NetSdrClientApp/Networking/UdpClientWrapper.cs @@ -1,11 +1,13 @@ -using System; +using System; using System.Net; using System.Net.Sockets; using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; +using System.Diagnostics.CodeAnalysis; +[ExcludeFromCodeCoverage] public class UdpClientWrapper : IUdpClient { private readonly IPEndPoint _localEndPoint; @@ -61,16 +63,7 @@ public void StopListening() public void Exit() { - try - { - _cts?.Cancel(); - _udpClient?.Close(); - Console.WriteLine("Stopped listening for UDP messages."); - } - catch (Exception ex) - { - Console.WriteLine($"Error while stopping: {ex.Message}"); - } + StopListening(); } public override int GetHashCode() diff --git a/NetSdrClientApp/Program.cs b/NetSdrClientApp/Program.cs index fda2e69..9577a7d 100644 --- a/NetSdrClientApp/Program.cs +++ b/NetSdrClientApp/Program.cs @@ -1,4 +1,7 @@ -using NetSdrClientApp; +// +// Excluded from coverage - entry point only + +using NetSdrClientApp; using NetSdrClientApp.Networking; Console.WriteLine(@"Usage: diff --git a/NetSdrClientAppTests/NetSdrClientAppTests.csproj b/NetSdrClientAppTests/NetSdrClientAppTests.csproj index 3cbc46a..5853912 100644 --- a/NetSdrClientAppTests/NetSdrClientAppTests.csproj +++ b/NetSdrClientAppTests/NetSdrClientAppTests.csproj @@ -1,7 +1,7 @@ - net8.0 + net10.0 enable enable @@ -11,11 +11,18 @@ - + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + - - - + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + diff --git a/NetSdrClientAppTests/NetSdrClientTests.cs b/NetSdrClientAppTests/NetSdrClientTests.cs index ad00c4f..936a525 100644 --- a/NetSdrClientAppTests/NetSdrClientTests.cs +++ b/NetSdrClientAppTests/NetSdrClientTests.cs @@ -115,5 +115,64 @@ 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 + await _client.StopIQAsync(); + + // assert — без з'єднання StopListening не викликається + _updMock.Verify(udp => udp.StopListening(), Times.Never); + Assert.That(_client.IQStarted, Is.False); + } + + [Test] + public async Task ChangeFrequencyAsyncTest() + { + // Arrange + await ConnectAsyncTest(); + + // Act + await _client.ChangeFrequencyAsync(20000000, 1); + + // Assert + _tcpMock.Verify(tcp => tcp.SendMessageAsync(It.IsAny()), Times.Exactly(4)); // 3 з connect + 1 + } + + [Test] + public async Task ChangeFrequencyNoConnectionTest() + { + // Act + await _client.ChangeFrequencyAsync(20000000, 1); + + // Assert + _tcpMock.Verify(tcp => tcp.SendMessageAsync(It.IsAny()), Times.Never); + } + + [Test] + public async Task StartIQSetsIQStartedTrueTest() + { + // Arrange + await ConnectAsyncTest(); + + // Act + await _client.StartIQAsync(); + + // Assert + Assert.That(_client.IQStarted, Is.True); + } + + [Test] + public async Task StopIQSetsIQStartedFalseTest() + { + // Arrange + await ConnectAsyncTest(); + await _client.StartIQAsync(); + + // Act + await _client.StopIQAsync(); + + // Assert + Assert.That(_client.IQStarted, Is.False); + } } diff --git a/NetSdrClientAppTests/NetSdrMessageHelperTests.cs b/NetSdrClientAppTests/NetSdrMessageHelperTests.cs index b40fff7..dfc34ce 100644 --- a/NetSdrClientAppTests/NetSdrMessageHelperTests.cs +++ b/NetSdrClientAppTests/NetSdrMessageHelperTests.cs @@ -64,6 +64,190 @@ public void GetDataItemMessageTest() Assert.That(parametersBytes.Count(), Is.EqualTo(parametersLength)); } - //TODO: add more NetSdrMessageHelper tests + [Test] + public void GetControlItemMessage_LengthIsCorrect() + { + var msg = NetSdrMessageHelper.GetControlItemMessage( + NetSdrMessageHelper.MsgTypes.SetControlItem, + NetSdrMessageHelper.ControlItemCodes.ReceiverFrequency, + new byte[6]); + + Assert.That(msg.Length, Is.EqualTo(10)); // 2 header + 2 code + 6 params + } + + [Test] + public void GetControlItemMessage_TypeIsEncodedCorrectly() + { + var type = NetSdrMessageHelper.MsgTypes.SetControlItem; + var msg = NetSdrMessageHelper.GetControlItemMessage( + type, + NetSdrMessageHelper.ControlItemCodes.ReceiverState, + new byte[4]); + + var num = BitConverter.ToUInt16(msg.Take(2).ToArray()); + var actualType = (NetSdrMessageHelper.MsgTypes)(num >> 13); + + Assert.That(actualType, Is.EqualTo(type)); + } + + [Test] + public void GetDataItemMessage_EmptyParams_ReturnsHeaderOnly() + { + var msg = NetSdrMessageHelper.GetDataItemMessage( + NetSdrMessageHelper.MsgTypes.DataItem0, + new byte[0]); + + Assert.That(msg.Length, Is.EqualTo(2)); // тільки header + } + + [Test] + public void GetDataItemMessage_TypeEncodedCorrectly() + { + var type = NetSdrMessageHelper.MsgTypes.DataItem0; + var msg = NetSdrMessageHelper.GetDataItemMessage(type, new byte[100]); + + var num = BitConverter.ToUInt16(msg.Take(2).ToArray()); + var actualType = (NetSdrMessageHelper.MsgTypes)(num >> 13); + + Assert.That(actualType, Is.EqualTo(type)); + } + + [Test] + public void TranslateMessage_ControlItem_ReturnsCorrectTypeAndCode() + { + // Arrange — побудувати повідомлення і одразу розібрати + var type = NetSdrMessageHelper.MsgTypes.SetControlItem; + var code = NetSdrMessageHelper.ControlItemCodes.ReceiverFrequency; + var parameters = new byte[] { 0x01, 0x02, 0x03 }; + var msg = NetSdrMessageHelper.GetControlItemMessage(type, code, parameters); + + // Act + var success = NetSdrMessageHelper.TranslateMessage(msg, out var outType, out var outCode, out var outSeq, out var body); + + // Assert + Assert.That(success, Is.True); + Assert.That(outType, Is.EqualTo(type)); + Assert.That(outCode, Is.EqualTo(code)); + Assert.That(outSeq, Is.EqualTo(0)); + Assert.That(body, Is.EqualTo(parameters)); + } + + [Test] + public void TranslateMessage_DataItem_ReturnsCorrectTypeAndSequence() + { + // Arrange + var type = NetSdrMessageHelper.MsgTypes.DataItem0; + var parameters = new byte[] { 0x10, 0x20, 0x30, 0x40 }; + var msg = NetSdrMessageHelper.GetDataItemMessage(type, parameters); + + // Act + var success = NetSdrMessageHelper.TranslateMessage(msg, out var outType, out _, out var outSeq, out var body); + + // Assert + Assert.That(success, Is.True); + Assert.That(outType, Is.EqualTo(type)); + Assert.That(body.Length, Is.GreaterThan(0)); + } + + [Test] + public void TranslateMessage_AckType_ParsedCorrectly() + { + var type = NetSdrMessageHelper.MsgTypes.Ack; + var code = NetSdrMessageHelper.ControlItemCodes.ReceiverState; + var msg = NetSdrMessageHelper.GetControlItemMessage(type, code, new byte[2]); + + var success = NetSdrMessageHelper.TranslateMessage(msg, out var outType, out var outCode, out _, out _); + + Assert.That(success, Is.True); + Assert.That(outType, Is.EqualTo(type)); + Assert.That(outCode, Is.EqualTo(code)); + } + + [Test] + public void GetSamples_16bit_ReturnsCorrectCount() + { + // Arrange — 8 байт = 4 семпли по 16 біт + var body = new byte[] { 0x01, 0x00, 0x02, 0x00, 0x03, 0x00, 0x04, 0x00 }; + + // Act + var samples = NetSdrMessageHelper.GetSamples(16, body).ToList(); + + // Assert + Assert.That(samples.Count, Is.EqualTo(4)); + } + + [Test] + public void GetSamples_16bit_ReturnsCorrectValues() + { + var body = new byte[] { 0x05, 0x00, 0x0A, 0x00 }; + + var samples = NetSdrMessageHelper.GetSamples(16, body).ToList(); + + Assert.That(samples[0], Is.EqualTo(5)); + Assert.That(samples[1], Is.EqualTo(10)); + } + + [Test] + public void GetSamples_8bit_ReturnsCorrectCount() + { + var body = new byte[] { 0x01, 0x02, 0x03, 0x04 }; + + var samples = NetSdrMessageHelper.GetSamples(8, body).ToList(); + + Assert.That(samples.Count, Is.EqualTo(4)); + } + + [Test] + public void GetSamples_EmptyBody_ReturnsNoSamples() + { + var samples = NetSdrMessageHelper.GetSamples(16, new byte[0]).ToList(); + + Assert.That(samples.Count, Is.EqualTo(0)); + } + + [Test] + public void GetSamples_InvalidSampleSize_ThrowsException() + { + // sampleSize > 32 біти (4 байти) — має кинути виняток + Assert.Throws(() => + NetSdrMessageHelper.GetSamples(64, new byte[] { 0x01 }).ToList()); + } + + [Test] + public void GetHeader_MessageTooLong_ThrowsArgumentException() + { + // параметри довжиною більше ніж maxMessageLength + var hugeParams = new byte[9000]; + + Assert.Throws(() => + NetSdrMessageHelper.GetControlItemMessage( + NetSdrMessageHelper.MsgTypes.SetControlItem, + NetSdrMessageHelper.ControlItemCodes.ReceiverState, + hugeParams)); + } + + [Test] + public void GetControlItemMessage_ZeroParams_LengthIsHeaderPlusCode() + { + var msg = NetSdrMessageHelper.GetControlItemMessage( + NetSdrMessageHelper.MsgTypes.CurrentControlItem, + NetSdrMessageHelper.ControlItemCodes.ADModes, + new byte[0]); + + // 2 байти header + 2 байти code = 4 + Assert.That(msg.Length, Is.EqualTo(4)); + } + + [Test] + public void GetDataItemMessage_NoneCode_NoCodeBytesInMessage() + { + var parameters = new byte[] { 0xAA, 0xBB }; + var msg = NetSdrMessageHelper.GetDataItemMessage( + NetSdrMessageHelper.MsgTypes.DataItem1, + parameters); + + // 2 байти header + 2 байти parameters = 4 (без code) + Assert.That(msg.Length, Is.EqualTo(4)); + } } } \ No newline at end of file