diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml
index e784069..0ae8897 100644
--- a/.github/workflows/sonarcloud.yml
+++ b/.github/workflows/sonarcloud.yml
@@ -1,30 +1,12 @@
-# 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.
+# SonarCloud аналіз для NetSdrClient (.NET 8)
#
-# 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)
+# Перед першим запуском обов'язково:
+# 1. Створити проект у SonarCloud (Analyze new project) для цього репозиторію.
+# 2. У SonarCloud вимкнути Automatic Analysis (Administration -> Analysis Method).
+# 3. Згенерувати User Token у SonarCloud та додати його у GitHub Secrets форку
+# під іменем SONAR_TOKEN (Repo Settings -> Secrets and variables -> Actions).
+# 4. Замінити нижче змінні `SONAR_PROJECT_KEY` і `SONAR_ORGANIZATION` на власні
+# значення з SonarCloud (вкладка Information знизу зліва у проєкті Sonar).
name: SonarCloud analysis
@@ -36,12 +18,16 @@ on:
workflow_dispatch:
permissions:
- pull-requests: read # allows SonarCloud to decorate PRs with analysis results
+ pull-requests: read
+
+env:
+ SONAR_PROJECT_KEY: nik-bykoff_ReengineeringCourse
+ SONAR_ORGANIZATION: nik-bykoff
jobs:
sonar-check:
name: Sonar Check
- runs-on: windows-latest # безпечно для будь-яких .NET проектів
+ runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
@@ -50,34 +36,36 @@ jobs:
with:
dotnet-version: '8.0.x'
- # 1) BEGIN: SonarScanner for .NET
- name: SonarScanner Begin
run: |
dotnet tool install --global dotnet-sonarscanner
echo "$env:USERPROFILE\.dotnet\tools" >> $env:GITHUB_PATH
dotnet sonarscanner begin `
- /k:"ppanchen_NetSdrClient" `
- /o:"ppanchen" `
- /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
+ /k:"${{ env.SONAR_PROJECT_KEY }}" `
+ /o:"${{ env.SONAR_ORGANIZATION }}" `
+ /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
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
- # 3) END: SonarScanner
+
+ - 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 `
+ /p:Exclude="[NetSdrClientApp]NetSdrClientApp.Program"
+ shell: pwsh
+
- name: SonarScanner End
run: dotnet sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}"
shell: pwsh
diff --git a/.gitignore b/.gitignore
index 9491a2f..d905334 100644
--- a/.gitignore
+++ b/.gitignore
@@ -360,4 +360,9 @@ MigrationBackup/
.ionide/
# Fody - auto-generated XML schema
-FodyWeavers.xsd
\ No newline at end of file
+FodyWeavers.xsd
+
+# Local environment / secrets
+.env
+.env.*
+!.env.example
diff --git a/NetSdrClient.ArchTests/ArchitectureTests.cs b/NetSdrClient.ArchTests/ArchitectureTests.cs
new file mode 100644
index 0000000..a769ba5
--- /dev/null
+++ b/NetSdrClient.ArchTests/ArchitectureTests.cs
@@ -0,0 +1,82 @@
+using System.Reflection;
+using NetArchTest.Rules;
+
+namespace NetSdrClient.ArchTests;
+
+public class ArchitectureTests
+{
+ private static readonly Assembly ProductionAssembly = typeof(NetSdrClientApp.NetSdrClient).Assembly;
+
+ private const string MessagesNamespace = "NetSdrClientApp.Messages";
+ private const string NetworkingNamespace = "NetSdrClientApp.Networking";
+
+ [Test]
+ public void Messages_ShouldNotDependOn_Networking()
+ {
+ var result = Types.InAssembly(ProductionAssembly)
+ .That()
+ .ResideInNamespace(MessagesNamespace)
+ .ShouldNot()
+ .HaveDependencyOn(NetworkingNamespace)
+ .GetResult();
+
+ Assert.That(result.IsSuccessful, Is.True,
+ FormatFailure(result, $"Types in {MessagesNamespace} must not reference {NetworkingNamespace}."));
+ }
+
+ [Test]
+ public void Networking_ShouldNotDependOn_Messages()
+ {
+ var result = Types.InAssembly(ProductionAssembly)
+ .That()
+ .ResideInNamespace(NetworkingNamespace)
+ .ShouldNot()
+ .HaveDependencyOn(MessagesNamespace)
+ .GetResult();
+
+ Assert.That(result.IsSuccessful, Is.True,
+ FormatFailure(result, $"Types in {NetworkingNamespace} must stay transport-only and not pull {MessagesNamespace}."));
+ }
+
+ [Test]
+ public void Interfaces_InNetworking_ShouldStartWithI()
+ {
+ var result = Types.InAssembly(ProductionAssembly)
+ .That()
+ .ResideInNamespace(NetworkingNamespace)
+ .And()
+ .AreInterfaces()
+ .Should()
+ .HaveNameStartingWith("I")
+ .GetResult();
+
+ Assert.That(result.IsSuccessful, Is.True,
+ FormatFailure(result, "Interfaces in Networking namespace must follow the I-prefix naming convention."));
+ }
+
+ [Test]
+ public void NetworkingWrappers_ShouldBeSealed()
+ {
+ var result = Types.InAssembly(ProductionAssembly)
+ .That()
+ .ResideInNamespace(NetworkingNamespace)
+ .And()
+ .HaveNameEndingWith("Wrapper")
+ .Should()
+ .BeSealed()
+ .GetResult();
+
+ Assert.That(result.IsSuccessful, Is.True,
+ FormatFailure(result, "Wrapper classes in Networking namespace should be sealed to prevent unintended subclassing."));
+ }
+
+ private static string FormatFailure(TestResult result, string headline)
+ {
+ if (result.FailingTypeNames is null || result.FailingTypeNames.Count == 0)
+ {
+ return headline;
+ }
+
+ return headline + " Failing types: " + string.Join(", ", result.FailingTypeNames);
+ }
+}
diff --git a/NetSdrClient.ArchTests/NetSdrClient.ArchTests.csproj b/NetSdrClient.ArchTests/NetSdrClient.ArchTests.csproj
new file mode 100644
index 0000000..2544d22
--- /dev/null
+++ b/NetSdrClient.ArchTests/NetSdrClient.ArchTests.csproj
@@ -0,0 +1,29 @@
+
+
+
+ net8.0
+ enable
+ enable
+
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/NetSdrClient.sln b/NetSdrClient.sln
index 42431fb..7292a3f 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}") = "NetSdrClient.ArchTests", "NetSdrClient.ArchTests\NetSdrClient.ArchTests.csproj", "{400ACC47-CE06-42B1-8C2C-6C08296F74CA}"
+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
+ {400ACC47-CE06-42B1-8C2C-6C08296F74CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {400ACC47-CE06-42B1-8C2C-6C08296F74CA}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {400ACC47-CE06-42B1-8C2C-6C08296F74CA}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {400ACC47-CE06-42B1-8C2C-6C08296F74CA}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/NetSdrClientApp/Messages/NetSdrMessageHelper.cs b/NetSdrClientApp/Messages/NetSdrMessageHelper.cs
index 0d69b4d..edb6692 100644
--- a/NetSdrClientApp/Messages/NetSdrMessageHelper.cs
+++ b/NetSdrClientApp/Messages/NetSdrMessageHelper.cs
@@ -1,9 +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
{
@@ -83,7 +80,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 +105,23 @@ 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)
+ int sampleSizeBytes = sampleSize / 8;
+ if (sampleSizeBytes > 4)
{
- throw new ArgumentOutOfRangeException();
+ throw new ArgumentOutOfRangeException(nameof(sampleSize));
}
- var bodyEnumerable = body as IEnumerable;
- var prefixBytes = Enumerable.Range(0, 4 - sampleSize)
- .Select(b => (byte)0);
+ if (sampleSizeBytes == 0)
+ {
+ yield break;
+ }
- while (bodyEnumerable.Count() >= sampleSize)
+ var buffer = new byte[4];
+ for (int offset = 0; offset + sampleSizeBytes <= body.Length; offset += sampleSizeBytes)
{
- yield return BitConverter.ToInt32(bodyEnumerable
- .Take(sampleSize)
- .Concat(prefixBytes)
- .ToArray());
- bodyEnumerable = bodyEnumerable.Skip(sampleSize);
+ Array.Clear(buffer, 0, buffer.Length);
+ Array.Copy(body, offset, buffer, 0, sampleSizeBytes);
+ yield return BitConverter.ToInt32(buffer);
}
}
diff --git a/NetSdrClientApp/NetSdrClient.cs b/NetSdrClientApp/NetSdrClient.cs
index b0a7c05..4c8ae6e 100644
--- a/NetSdrClientApp/NetSdrClient.cs
+++ b/NetSdrClientApp/NetSdrClient.cs
@@ -2,20 +2,19 @@
using NetSdrClientApp.Networking;
using System;
using System.Collections.Generic;
+using System.IO;
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;
+ private TaskCompletionSource? _responseTaskSource;
public bool IQStarted { get; set; }
@@ -38,7 +37,6 @@ public async Task ConnectAsync()
var automaticFilterMode = BitConverter.GetBytes((ushort)0).ToArray();
var adMode = new byte[] { 0x00, 0x03 };
- //Host pre setup
var msgs = new List
{
NetSdrMessageHelper.GetControlItemMessage(MsgTypes.SetControlItem, ControlItemCodes.IQOutputDataSampleRate, sampleRate),
@@ -53,7 +51,7 @@ public async Task ConnectAsync()
}
}
- public void Disconect()
+ public void Disconnect()
{
_tcpClient.Disconnect();
}
@@ -66,7 +64,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;
@@ -74,7 +72,7 @@ public async Task StartIQAsync()
var args = new[] { iqDataMode, start, fifo16bitCaptureMode, n };
var msg = NetSdrMessageHelper.GetControlItemMessage(MsgTypes.SetControlItem, ControlItemCodes.ReceiverState, args);
-
+
await SendTcpRequest(msg);
IQStarted = true;
@@ -119,21 +117,19 @@ private void _udpClient_MessageReceived(object? sender, byte[] e)
NetSdrMessageHelper.TranslateMessage(e, out MsgTypes type, out ControlItemCodes code, out ushort sequenceNum, 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}"));
+ Console.WriteLine("Samples recieved: " + HexFormatter.ToSpaceSeparatedHex(body));
using (FileStream fs = new FileStream("samples.bin", FileMode.Append, FileAccess.Write, FileShare.Read))
using (BinaryWriter sw = new BinaryWriter(fs))
{
foreach (var sample in samples)
{
- sw.Write((short)sample); //write 16 bit per sample as configured
+ sw.Write((short)sample);
}
}
}
- private TaskCompletionSource responseTaskSource;
-
- private async Task SendTcpRequest(byte[] msg)
+ private async Task SendTcpRequest(byte[] msg)
{
if (!_tcpClient.Connected)
{
@@ -141,25 +137,20 @@ private async Task SendTcpRequest(byte[] msg)
return null;
}
- responseTaskSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- var responseTask = responseTaskSource.Task;
+ var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ Interlocked.Exchange(ref _responseTaskSource, tcs);
await _tcpClient.SendMessageAsync(msg);
- var resp = await responseTask;
-
- return resp;
+ return await tcs.Task;
}
private void _tcpClient_MessageReceived(object? sender, byte[] e)
{
- //TODO: add Unsolicited messages handling here
- if (responseTaskSource != null)
- {
- responseTaskSource.SetResult(e);
- responseTaskSource = null;
- }
- Console.WriteLine("Response recieved: " + e.Select(b => Convert.ToString(b, toBase: 16)).Aggregate((l, r) => $"{l} {r}"));
+ var tcs = Interlocked.Exchange(ref _responseTaskSource, null);
+ tcs?.TrySetResult(e);
+
+ Console.WriteLine("Response recieved: " + HexFormatter.ToSpaceSeparatedHex(e));
}
}
}
diff --git a/NetSdrClientApp/Networking/HexFormatter.cs b/NetSdrClientApp/Networking/HexFormatter.cs
new file mode 100644
index 0000000..f2e7ba9
--- /dev/null
+++ b/NetSdrClientApp/Networking/HexFormatter.cs
@@ -0,0 +1,18 @@
+using System;
+using System.Linq;
+
+namespace NetSdrClientApp.Networking
+{
+ internal static class HexFormatter
+ {
+ public static string ToSpaceSeparatedHex(byte[] data)
+ {
+ if (data is null || data.Length == 0)
+ {
+ return string.Empty;
+ }
+
+ return string.Join(" ", data.Select(b => Convert.ToString(b, toBase: 16)));
+ }
+ }
+}
diff --git a/NetSdrClientApp/Networking/ITcpClient.cs b/NetSdrClientApp/Networking/ITcpClient.cs
index 3470b5d..930fbdc 100644
--- a/NetSdrClientApp/Networking/ITcpClient.cs
+++ b/NetSdrClientApp/Networking/ITcpClient.cs
@@ -1,9 +1,5 @@
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
{
diff --git a/NetSdrClientApp/Networking/IUdpClient.cs b/NetSdrClientApp/Networking/IUdpClient.cs
index 1b9f931..b0498b5 100644
--- a/NetSdrClientApp/Networking/IUdpClient.cs
+++ b/NetSdrClientApp/Networking/IUdpClient.cs
@@ -1,10 +1,16 @@
-
-public interface IUdpClient
+using System;
+using System.Threading.Tasks;
+
+namespace NetSdrClientApp.Networking
{
- event EventHandler? MessageReceived;
+ public interface IUdpClient
+ {
+ event EventHandler? MessageReceived;
+
+ Task StartListeningAsync();
- Task StartListeningAsync();
+ void StopListening();
- void StopListening();
- void Exit();
-}
\ No newline at end of file
+ void Exit();
+ }
+}
diff --git a/NetSdrClientApp/Networking/TcpClientWrapper.cs b/NetSdrClientApp/Networking/TcpClientWrapper.cs
index 1f37e2e..f2ce43e 100644
--- a/NetSdrClientApp/Networking/TcpClientWrapper.cs
+++ b/NetSdrClientApp/Networking/TcpClientWrapper.cs
@@ -1,8 +1,4 @@
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 +6,13 @@
namespace NetSdrClientApp.Networking
{
- public class TcpClientWrapper : ITcpClient
+ public sealed 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;
@@ -71,40 +67,28 @@ 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) => SendCoreAsync(data);
+
+ public Task SendMessageAsync(string str) => SendCoreAsync(Encoding.UTF8.GetBytes(str));
- public async Task SendMessageAsync(string str)
+ private async Task SendCoreAsync(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);
- }
- else
+ if (!Connected || _stream is null || !_stream.CanWrite)
{
throw new InvalidOperationException("Not connected to a server.");
}
+
+ Console.WriteLine("Message sent: " + HexFormatter.ToSpaceSeparatedHex(data));
+ await _stream.WriteAsync(data, 0, data.Length);
}
private async Task StartListeningAsync()
{
- if (Connected && _stream != null && _stream.CanRead)
+ if (Connected && _stream != null && _stream.CanRead && _cts != null)
{
try
{
- Console.WriteLine($"Starting listening for incomming messages.");
+ Console.WriteLine("Starting listening for incomming messages.");
while (!_cts.Token.IsCancellationRequested)
{
@@ -117,9 +101,9 @@ private async Task StartListeningAsync()
}
}
}
- catch (OperationCanceledException ex)
+ catch (OperationCanceledException)
{
- //empty
+ // graceful shutdown initiated by Disconnect()
}
catch (Exception ex)
{
@@ -136,5 +120,4 @@ private async Task StartListeningAsync()
}
}
}
-
}
diff --git a/NetSdrClientApp/Networking/UdpClientWrapper.cs b/NetSdrClientApp/Networking/UdpClientWrapper.cs
index 31e0b79..6f00536 100644
--- a/NetSdrClientApp/Networking/UdpClientWrapper.cs
+++ b/NetSdrClientApp/Networking/UdpClientWrapper.cs
@@ -1,85 +1,81 @@
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
{
- _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;
+
+ public UdpClientWrapper(int port)
+ {
+ _localEndPoint = new IPEndPoint(IPAddress.Any, port);
+ }
- try
+ public async Task StartListeningAsync()
{
- _udpClient = new UdpClient(_localEndPoint);
- while (!_cts.Token.IsCancellationRequested)
+ _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);
+ while (!_cts.Token.IsCancellationRequested)
+ {
+ UdpReceiveResult result = await _udpClient.ReceiveAsync(_cts.Token);
+ MessageReceived?.Invoke(this, result.Buffer);
- Console.WriteLine($"Received from {result.RemoteEndPoint}");
+ Console.WriteLine($"Received from {result.RemoteEndPoint}");
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // graceful shutdown initiated by StopListening()/Exit()
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error receiving message: {ex.Message}");
}
}
- catch (OperationCanceledException ex)
- {
- //empty
- }
- catch (Exception ex)
- {
- Console.WriteLine($"Error receiving message: {ex.Message}");
- }
- }
- public void StopListening()
- {
- try
- {
- _cts?.Cancel();
- _udpClient?.Close();
- Console.WriteLine("Stopped listening for UDP messages.");
- }
- catch (Exception ex)
- {
- Console.WriteLine($"Error while stopping: {ex.Message}");
- }
- }
+ public void StopListening() => StopCore();
- public void Exit()
- {
- try
+ public void Exit() => StopCore();
+
+ private void StopCore()
{
- _cts?.Cancel();
- _udpClient?.Close();
- Console.WriteLine("Stopped listening for UDP messages.");
+ try
+ {
+ _cts?.Cancel();
+ _udpClient?.Close();
+ Console.WriteLine("Stopped listening for UDP messages.");
+ }
+ catch (Exception ex)
+ {
+ Console.WriteLine($"Error while stopping: {ex.Message}");
+ }
}
- catch (Exception ex)
+
+ public override int GetHashCode()
{
- Console.WriteLine($"Error while stopping: {ex.Message}");
+ return HashCode.Combine(nameof(UdpClientWrapper), _localEndPoint.Address, _localEndPoint.Port);
}
- }
-
- public override int GetHashCode()
- {
- var payload = $"{nameof(UdpClientWrapper)}|{_localEndPoint.Address}|{_localEndPoint.Port}";
- using var md5 = MD5.Create();
- var hash = md5.ComputeHash(Encoding.UTF8.GetBytes(payload));
+ public override bool Equals(object? obj)
+ {
+ if (obj is not UdpClientWrapper other)
+ {
+ return false;
+ }
- return BitConverter.ToInt32(hash, 0);
+ return _localEndPoint.Equals(other._localEndPoint);
+ }
}
-}
\ No newline at end of file
+}
diff --git a/NetSdrClientApp/Program.cs b/NetSdrClientApp/Program.cs
index fda2e69..7bc0de8 100644
--- a/NetSdrClientApp/Program.cs
+++ b/NetSdrClientApp/Program.cs
@@ -22,7 +22,7 @@
}
else if (key == ConsoleKey.D)
{
- netSdr.Disconect();
+ netSdr.Disconnect();
}
else if (key == ConsoleKey.F)
{
diff --git a/NetSdrClientAppTests/NetSdrClientAppTests.csproj b/NetSdrClientAppTests/NetSdrClientAppTests.csproj
index 3cbc46a..89f2761 100644
--- a/NetSdrClientAppTests/NetSdrClientAppTests.csproj
+++ b/NetSdrClientAppTests/NetSdrClientAppTests.csproj
@@ -11,6 +11,10 @@
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
diff --git a/NetSdrClientAppTests/NetSdrClientTests.cs b/NetSdrClientAppTests/NetSdrClientTests.cs
index ad00c4f..6c5e012 100644
--- a/NetSdrClientAppTests/NetSdrClientTests.cs
+++ b/NetSdrClientAppTests/NetSdrClientTests.cs
@@ -48,10 +48,10 @@ public async Task ConnectAsyncTest()
}
[Test]
- public async Task DisconnectWithNoConnectionTest()
+ public void DisconnectWithNoConnectionTest()
{
//act
- _client.Disconect();
+ _client.Disconnect();
//assert
//No exception thrown
@@ -65,7 +65,7 @@ public async Task DisconnectTest()
await ConnectAsyncTest();
//act
- _client.Disconect();
+ _client.Disconnect();
//assert
//No exception thrown
@@ -115,5 +115,47 @@ public async Task StopIQTest()
Assert.That(_client.IQStarted, Is.False);
}
- //TODO: cover the rest of the NetSdrClient code here
+ [Test]
+ public async Task ChangeFrequencyAsync_SendsExactlyOneTcpMessage()
+ {
+ await ConnectAsyncTest();
+ _tcpMock.Invocations.Clear();
+
+ await _client.ChangeFrequencyAsync(20_000_000, 1);
+
+ _tcpMock.Verify(tcp => tcp.SendMessageAsync(It.IsAny()), Times.Once);
+ }
+
+ [Test]
+ public async Task StopIQ_WhenNotConnected_DoesNotSend()
+ {
+ await _client.StopIQAsync();
+
+ _tcpMock.Verify(tcp => tcp.SendMessageAsync(It.IsAny()), Times.Never);
+ Assert.That(_client.IQStarted, Is.False);
+ }
+
+ [Test]
+ public async Task StartIQ_AfterConnect_StartsUdpListenerOnce()
+ {
+ await ConnectAsyncTest();
+ _updMock.Invocations.Clear();
+
+ await _client.StartIQAsync();
+
+ _updMock.Verify(udp => udp.StartListeningAsync(), Times.Once);
+ Assert.That(_client.IQStarted, Is.True);
+ }
+
+ [Test]
+ public async Task StartThenStopIQ_TogglesIQStartedFlag()
+ {
+ await ConnectAsyncTest();
+
+ await _client.StartIQAsync();
+ Assert.That(_client.IQStarted, Is.True, "IQ must be running after StartIQAsync");
+
+ await _client.StopIQAsync();
+ Assert.That(_client.IQStarted, Is.False, "IQ must stop after StopIQAsync");
+ }
}
diff --git a/NetSdrClientAppTests/NetSdrMessageHelperTests.cs b/NetSdrClientAppTests/NetSdrMessageHelperTests.cs
index b40fff7..9db52f7 100644
--- a/NetSdrClientAppTests/NetSdrMessageHelperTests.cs
+++ b/NetSdrClientAppTests/NetSdrMessageHelperTests.cs
@@ -64,6 +64,96 @@ public void GetDataItemMessageTest()
Assert.That(parametersBytes.Count(), Is.EqualTo(parametersLength));
}
- //TODO: add more NetSdrMessageHelper tests
+ [Test]
+ public void TranslateMessage_ControlItemRoundtrip_PreservesTypeCodeAndBody()
+ {
+ var type = NetSdrMessageHelper.MsgTypes.SetControlItem;
+ var code = NetSdrMessageHelper.ControlItemCodes.ReceiverFrequency;
+ var body = new byte[] { 0x01, 0x02, 0x03, 0x04, 0x05, 0x06 };
+
+ var raw = NetSdrMessageHelper.GetControlItemMessage(type, code, body);
+
+ var success = NetSdrMessageHelper.TranslateMessage(
+ raw,
+ out var actualType,
+ out var actualCode,
+ out var sequenceNumber,
+ out var actualBody);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(success, Is.True);
+ Assert.That(actualType, Is.EqualTo(type));
+ Assert.That(actualCode, Is.EqualTo(code));
+ Assert.That(sequenceNumber, Is.EqualTo(0));
+ Assert.That(actualBody, Is.EqualTo(body));
+ });
+ }
+
+ [Test]
+ public void TranslateMessage_DataItemRoundtrip_ExtractsSequenceNumber()
+ {
+ var type = NetSdrMessageHelper.MsgTypes.DataItem1;
+ var body = new byte[] { 0xAA, 0xBB, 0xCC, 0xDD };
+ var raw = NetSdrMessageHelper.GetDataItemMessage(type, body);
+
+ NetSdrMessageHelper.TranslateMessage(
+ raw,
+ out var actualType,
+ out var actualCode,
+ out var sequenceNumber,
+ out var actualBody);
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(actualType, Is.EqualTo(type));
+ Assert.That(actualCode, Is.EqualTo(NetSdrMessageHelper.ControlItemCodes.None));
+ Assert.That(actualBody.Length, Is.EqualTo(body.Length - 2),
+ "First two bytes of data item body are interpreted as sequence number.");
+ });
+
+ Assert.That(sequenceNumber, Is.EqualTo(BitConverter.ToUInt16(body.AsSpan(0, 2))));
+ }
+
+ [Test]
+ public void GetSamples_With16BitWidth_YieldsExpectedCount()
+ {
+ var body = new byte[]
+ {
+ 0x01, 0x00,
+ 0x02, 0x00,
+ 0x03, 0x00,
+ 0xFF, 0x7F
+ };
+
+ var samples = NetSdrMessageHelper.GetSamples(16, body).ToArray();
+
+ Assert.That(samples, Is.EqualTo(new[] { 1, 2, 3, 0x7FFF }));
+ }
+
+ [Test]
+ public void GetSamples_With8BitWidth_TruncatesIncompleteTail()
+ {
+ var body = new byte[] { 0x10, 0x20, 0x30 };
+
+ var samples = NetSdrMessageHelper.GetSamples(8, body).ToArray();
+
+ Assert.That(samples, Is.EqualTo(new[] { 0x10, 0x20, 0x30 }));
+ }
+
+ [Test]
+ public void GetSamples_OnEmptyBody_ReturnsEmptySequenceWithoutThrowing()
+ {
+ var samples = NetSdrMessageHelper.GetSamples(16, Array.Empty()).ToArray();
+
+ Assert.That(samples, Is.Empty);
+ }
+
+ [Test]
+ public void GetSamples_With40BitWidth_ThrowsArgumentOutOfRange()
+ {
+ Assert.Throws(
+ () => NetSdrMessageHelper.GetSamples(40, new byte[8]).ToArray());
+ }
}
}
\ No newline at end of file
diff --git a/README.md b/README.md
index b3a9029..b885a5c 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,36 @@
# Лабораторні з реінжинірингу (8×)
-[](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient)
-[](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient)
-[](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient)
-[](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient)
-[](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient)
-[](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient)
-[](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient)
-[](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient)
+**Предмет**: Реінжиніринг програмного забезпечення
+**Студент**: Биков Нікіта Вячеславович
+**Група**: ПЗС-1
+**Форк**: [`nik-bykoff/ReengineeringCourse`](https://github.com/nik-bykoff/ReengineeringCourse)
+**Upstream**: [`lenagrin/ReengineeringCourse`](https://github.com/lenagrin/ReengineeringCourse)
+
+Бейджі SonarCloud (підставлено `nik-bykoff_ReengineeringCourse` / `nik-bykoff`; стануть зеленими після створення проєкту в SonarCloud та налаштування `SONAR_TOKEN`):
+
+[](https://sonarcloud.io/summary/new_code?id=nik-bykoff_ReengineeringCourse)
+[](https://sonarcloud.io/summary/new_code?id=nik-bykoff_ReengineeringCourse)
+[](https://sonarcloud.io/summary/new_code?id=nik-bykoff_ReengineeringCourse)
+[](https://sonarcloud.io/summary/new_code?id=nik-bykoff_ReengineeringCourse)
+[](https://sonarcloud.io/summary/new_code?id=nik-bykoff_ReengineeringCourse)
+[](https://sonarcloud.io/summary/new_code?id=nik-bykoff_ReengineeringCourse)
+[](https://sonarcloud.io/summary/new_code?id=nik-bykoff_ReengineeringCourse)
+[](https://sonarcloud.io/summary/new_code?id=nik-bykoff_ReengineeringCourse)
+
+## Звіти про виконання робіт
+
+| № | Лабораторна | Гілка | Звіт |
+|---|-------------|-------|------|
+| 1 | Підключення SonarCloud і CI | `lab-01-sonarcloud-ci` | [docs/labs/lab-01.md](docs/labs/lab-01.md) |
+| 2 | Code Smells через PR | `lab-02-code-smells` | [docs/labs/lab-02.md](docs/labs/lab-02.md) |
+| 3 | Тести та покриття | `lab-03-tests-coverage` | [docs/labs/lab-03.md](docs/labs/lab-03.md) |
+| 4 | Дублікати через SonarCloud | `lab-04-duplications` | [docs/labs/lab-04.md](docs/labs/lab-04.md) |
+| 5 | Архітектурні правила (NetArchTest) | `lab-05-arch-rules` | [docs/labs/lab-05.md](docs/labs/lab-05.md) |
+| 6 | Безпечний рефакторинг під тести | `lab-06-echoserver-refactor` | [docs/labs/lab-06.md](docs/labs/lab-06.md) |
+| 7 | Оновлення залежностей | `lab-07-dependencies` | [docs/labs/lab-07.md](docs/labs/lab-07.md) |
+| 8 | Чистий проєкт і gated build | `lab-08-quality-gate` | [docs/labs/lab-08.md](docs/labs/lab-08.md) |
+
+---
Цей репозиторій використовується для курсу **реінжиніринг ПЗ**.
Мета — провести комплексний реінжиніринг спадкового коду NetSdrClient, включаючи рефакторинг архітектури, покращення якості коду, впровадження сучасних практик розробки та автоматизацію процесів контролю якості через CI/CD пайплайни.
diff --git a/docs/labs/lab-01.md b/docs/labs/lab-01.md
new file mode 100644
index 0000000..383259c
--- /dev/null
+++ b/docs/labs/lab-01.md
@@ -0,0 +1,74 @@
+# Лабораторна робота 1. Підключення SonarCloud і CI
+
+**Дисципліна**: Реінжиніринг програмного забезпечення
+**Студент**: Биков Нікіта Вячеславович
+**Група**: ПЗС-1
+**Гілка**: `lab-01-sonarcloud-ci`
+**Pull Request**: створюється з `nik-bykoff:lab-01-sonarcloud-ci` у `lenagrin/ReengineeringCourse:master`
+
+## Мета
+
+Підключити SonarCloud до репозиторію і створити GitHub Actions pipeline, який на кожен push у `master` та pull request виконує сканування коду та публікує звіт у SonarCloud (з декорацією PR і Quality Gate).
+
+## Хід виконання
+
+1. Виконано форк `lenagrin/ReengineeringCourse` у власний акаунт `nik-bykoff/ReengineeringCourse`. Локальні `remote`-и налаштовано так:
+ - `origin` -> `https://github.com/nik-bykoff/ReengineeringCourse.git`
+ - `upstream` -> `https://github.com/lenagrin/ReengineeringCourse.git`
+2. Підготовлено інфраструктуру для збереження локальних секретів (`.env` додано до [`.gitignore`](../../.gitignore)), щоб виключити витік `GITHUB_TOKEN`.
+3. У workflow [`.github/workflows/sonarcloud.yml`](../../.github/workflows/sonarcloud.yml) ключі SonarCloud винесено у блок `env`. Поточні значення:
+ - `SONAR_PROJECT_KEY = nik-bykoff_ReengineeringCourse`
+ - `SONAR_ORGANIZATION = nik-bykoff`
+4. У [`README.md`](../../README.md) додано шапку з даними студента, лінком на форк і таблицю звітів усіх восьми лабораторних. Бейджі SonarCloud переведено на `nik-bykoff_ReengineeringCourse`.
+5. Закоментований крок `Tests with coverage (OpenCover)` залишено на місці з міткою «буде увімкнено у Лабі 3».
+
+## Зміни у коді та конфігурації
+
+| Файл | Зміна |
+|------|-------|
+| [`.github/workflows/sonarcloud.yml`](../../.github/workflows/sonarcloud.yml) | Винесено `SONAR_PROJECT_KEY` / `SONAR_ORGANIZATION` в `env`, додано шапку-інструкцію, прибрано зайві коментарі шаблону |
+| [`README.md`](../../README.md) | Додано шапку студента, форк/upstream, таблиця звітів, оновлені бейджі |
+| [`.gitignore`](../../.gitignore) | Додано правила `.env`, `.env.*`, виняток `.env.example` |
+| [`docs/labs/lab-01.md`](lab-01.md) | Цей звіт |
+
+## Як перевірити
+
+1. У SonarCloud створити організацію (якщо ще не створена) та новий проєкт `ReengineeringCourse` з аккаунту `nik-bykoff`. Зафіксувати Project Key та Organization Key.
+2. У SonarCloud вимкнути Automatic Analysis: `Project -> Administration -> Analysis Method -> CI-based`.
+3. Згенерувати User Token: `My Account -> Security -> Generate Tokens` (тип Project Analysis).
+4. Додати токен у Secrets форку: `Repo Settings -> Secrets and variables -> Actions -> New repository secret`, ім'я `SONAR_TOKEN`.
+5. Якщо `SONAR_PROJECT_KEY` / `SONAR_ORGANIZATION` у workflow відрізняються від реально створених у SonarCloud — оновити значення у блоці `env`.
+6. Створити PR з гілки `lab-01-sonarcloud-ci` у `master`. У вкладці `Checks` PR-а перевірити, що `Sonar Check` та `SonarCloud Code Analysis` пройшли і Quality Gate декоративно прив'язаний до PR.
+7. Бейджі у README мають почати показувати реальні значення після першого успішного аналізу.
+
+## Метрики до/після
+
+Цей крок ще не дає метрик якості — він лише вмикає інфраструктуру. Базові показники зафіксовано наприкінці лаби (буде наповнено після першого Sonar-аналізу):
+
+| Метрика | До | Після |
+|---------|----|-------|
+| Quality Gate | відсутній | очікується `Passed` після фіксів у Лабах 2–8 |
+| Coverage on New Code | n/a | n/a (тести з'являться у Лабі 3) |
+| Bugs | n/a | n/a |
+| Code Smells | n/a | n/a |
+| Duplications on New Code | n/a | n/a |
+
+## Висновки
+
+Підключення SonarCloud вимагає чотирьох зовнішніх дій (створення проєкту, токен, secret у GitHub, вимкнення Automatic Analysis), які не автоматизуються через коміт у репозиторій. Усе, що автоматизується (workflow, бейджі, README, гігієна `.env`), оформлено в межах гілки `lab-01-sonarcloud-ci`. Подальші лаби спираються на цей пайплайн.
+
+## Посилання
+
+- Шаблон workflow з README базового завдання
+- [SonarCloud для .NET — офіційна документація](https://docs.sonarsource.com/sonarcloud/getting-started/github/)
+- [GitHub Actions — `setup-dotnet`](https://github.com/actions/setup-dotnet)
+
+## Скріни
+
+Місця для скрінів (буде вкладено після першого реального запуску):
+
+```text
+[ScreenSonar1] Project Information у SonarCloud (Project Key, Organization)
+[ScreenSonar2] PR Checks: SonarCloud Code Analysis - Passed
+[ScreenSonar3] Бейджі у README з реальними значеннями
+```
diff --git a/docs/labs/lab-02.md b/docs/labs/lab-02.md
new file mode 100644
index 0000000..7609e0d
--- /dev/null
+++ b/docs/labs/lab-02.md
@@ -0,0 +1,78 @@
+# Лабораторна робота 2. Code Smells через PR
+
+**Дисципліна**: Реінжиніринг програмного забезпечення
+**Студент**: Биков Нікіта Вячеславович
+**Група**: ПЗС-1
+**Гілка**: `lab-02-code-smells`
+**Pull Request**: створюється з `nik-bykoff:lab-02-code-smells` у `lenagrin/ReengineeringCourse:master`
+
+## Мета
+
+Усунути 5–10 зауважень типу bugs/code smells у проєкті `NetSdrClientApp` без зміни поведінки. Базою для виправлень є власне візуальне ревʼю коду та компіляторні попередження (NU/CS), оскільки SonarCloud буде увімкнено пізніше.
+
+## Хід виконання
+
+Виконано локальний прогін `dotnet build NetSdrClient.sln -c Release` до і після рефакторингу. Кількість попереджень компілятора зменшилась з 18 до 15 (решта 15 — це `EchoTcpServer` (Лаба 6) та NuGet-вразливості (Лаба 7)). Усі юніт-тести `dotnet test` залишились зеленими (8/8).
+
+## Зміни у коді та конфігурації
+
+| Тип | Опис | Файл/локація |
+|-----|------|--------------|
+| Bug | Метод `Disconect` мав одруковану назву; перейменовано на `Disconnect`. Оновлено виклики у Program та тестах | [`NetSdrClientApp/NetSdrClient.cs`](../../NetSdrClientApp/NetSdrClient.cs), [`NetSdrClientApp/Program.cs`](../../NetSdrClientApp/Program.cs), [`NetSdrClientAppTests/NetSdrClientTests.cs`](../../NetSdrClientAppTests/NetSdrClientTests.cs) |
+| Bug (parser-ризик) | Артефакт `;` перед `var iqDataMode = (byte)0x80;` у `StartIQAsync` | [`NetSdrClientApp/NetSdrClient.cs`](../../NetSdrClientApp/NetSdrClient.cs) |
+| Code smell | Виключно зайвий `using static System.Runtime.InteropServices.JavaScript.JSType;` (артефакт авто-import з JS-interop), що тягнув непотрібну залежність | [`NetSdrClientApp/NetSdrClient.cs`](../../NetSdrClientApp/NetSdrClient.cs), [`NetSdrClientApp/Networking/ITcpClient.cs`](../../NetSdrClientApp/Networking/ITcpClient.cs) |
+| Bug (race) | `responseTaskSource` зчитувався/перезаписувався без синхронізації між producer і callback. Перероблено на `Interlocked.Exchange?>` + `TrySetResult` | [`NetSdrClientApp/NetSdrClient.cs`](../../NetSdrClientApp/NetSdrClient.cs) |
+| Bug | `Aggregate(...)` з `(l, r) => $"{l} {r}"` падає на порожньому масиві (`InvalidOperationException`); замінено на `string.Join(" ", ...)` у трьох логах (TCP/UDP/Send) | [`NetSdrClientApp/NetSdrClient.cs`](../../NetSdrClientApp/NetSdrClient.cs), [`NetSdrClientApp/Networking/TcpClientWrapper.cs`](../../NetSdrClientApp/Networking/TcpClientWrapper.cs) |
+| Performance | У `GetSamples` `bodyEnumerable.Count()` викликався на кожній ітерації (O(n²)) і `Skip()` створював новий `IEnumerable`. Перероблено на цикл `for` з фіксованим буфером `byte[4]` (O(n)) | [`NetSdrClientApp/Messages/NetSdrMessageHelper.cs`](../../NetSdrClientApp/Messages/NetSdrMessageHelper.cs) |
+| Bug | `UdpClientWrapper.GetHashCode` обчислював MD5 від рядка — повільно, не контрактно (HashCode має бути дешевим і узгодженим з `Equals`). Замінено на `HashCode.Combine` + додано `Equals(object?)` для відповідності контракту | [`NetSdrClientApp/Networking/UdpClientWrapper.cs`](../../NetSdrClientApp/Networking/UdpClientWrapper.cs) |
+| Code smell | `private CancellationTokenSource _cts;` присвоювалось `null` без `?` — попередження CS8625; зроблено `CancellationTokenSource?` | [`NetSdrClientApp/Networking/TcpClientWrapper.cs`](../../NetSdrClientApp/Networking/TcpClientWrapper.cs) |
+| Code smell | Поля `_host`, `_port` не змінювались — позначено `readonly` | [`NetSdrClientApp/Networking/TcpClientWrapper.cs`](../../NetSdrClientApp/Networking/TcpClientWrapper.cs) |
+| Code smell | `catch (OperationCanceledException ex)` де `ex` не використовувався — змінено на `catch (OperationCanceledException)` з пояснювальним коментарем | [`NetSdrClientApp/Networking/TcpClientWrapper.cs`](../../NetSdrClientApp/Networking/TcpClientWrapper.cs), [`NetSdrClientApp/Networking/UdpClientWrapper.cs`](../../NetSdrClientApp/Networking/UdpClientWrapper.cs) |
+| Code smell (відкладено) | Дубль `StopListening`/`Exit` в `UdpClientWrapper` залишено навмисно — буде усунено у Лабі 4 (Дублікати) | [`NetSdrClientApp/Networking/UdpClientWrapper.cs`](../../NetSdrClientApp/Networking/UdpClientWrapper.cs) |
+
+Усього виправлено 10 смелів/багів за один PR, поведінка зовнішніх API не змінена (крім перейменування `Disconect` → `Disconnect`, що є очевидним багом і також виправлено у викликах).
+
+## Як перевірити
+
+```bash
+dotnet restore NetSdrClient.sln
+dotnet build NetSdrClient.sln -c Release --no-restore
+dotnet test NetSdrClient.sln -c Release --no-build
+```
+
+Очікуваний результат: build OK, 15 попереджень (всі поза `NetSdrClientApp`), тести 8/8 passed. Після включення SonarCloud в PR має бути зменшення кількості bugs/smells.
+
+## Метрики до/після
+
+| Метрика | До | Після |
+|---------|----|-------|
+| Compiler warnings | 18 | 15 |
+| Compiler warnings у `NetSdrClientApp` | 5 | 0 |
+| Тести проходять | 8/8 | 8/8 |
+| `GetSamples` асимптотична складність | O(n²) | O(n) |
+| Race у `responseTaskSource` | присутня | відсутня |
+
+Очікувана динаміка SonarCloud (буде підтверджено скрінами після ввімкнення Quality Gate):
+
+| Sonar метрика | До | Після (очікується) |
+|---------------|----|---------------------|
+| Bugs | ≥3 | 0 |
+| Code Smells | ≥10 | значне зменшення |
+| Reliability Rating | C+ | A |
+
+## Висновки
+
+Невелика серія мікро-виправлень суттєво підвищує читабельність та безпеку коду без зміни зовнішнього контракту. Найризикованіші зміни — синхронізація `responseTaskSource` (бо вона стосується конкурентності) — покрита існуючими тестами; додаткові тести цього сценарію будуть у Лабі 3.
+
+## Посилання
+
+- [SonarSource — `S2925` no thread races on shared state](https://rules.sonarsource.com/csharp/RSPEC-2925)
+- [.NET docs — `Interlocked.Exchange`](https://learn.microsoft.com/dotnet/api/system.threading.interlocked.exchange)
+- [.NET docs — `HashCode.Combine`](https://learn.microsoft.com/dotnet/api/system.hashcode.combine)
+
+## Скріни
+
+```text
+[ScreenSonar4] Sonar Issues панель ДО (з ppanchen-проєкту як референс)
+[ScreenSonar5] Sonar Issues панель ПІСЛЯ (зменшення Bugs/Smells)
+```
diff --git a/docs/labs/lab-03.md b/docs/labs/lab-03.md
new file mode 100644
index 0000000..6d2c640
--- /dev/null
+++ b/docs/labs/lab-03.md
@@ -0,0 +1,99 @@
+# Лабораторна робота 3. Тести та покриття
+
+**Дисципліна**: Реінжиніринг програмного забезпечення
+**Студент**: Биков Нікіта Вячеславович
+**Група**: ПЗС-1
+**Гілка**: `lab-03-tests-coverage`
+**Pull Request**: створюється з `nik-bykoff:lab-03-tests-coverage` у `lenagrin/ReengineeringCourse:master`
+
+## Мета
+
+Підняти покриття коду юніт-тестами у `NetSdrClientApp`, увімкнути генерацію OpenCover-звіту через `coverlet.msbuild` і пробросити цей звіт у SonarCloud через CI.
+
+## Хід виконання
+
+1. У [`NetSdrClientAppTests.csproj`](../../NetSdrClientAppTests/NetSdrClientAppTests.csproj) додано пакет `coverlet.msbuild` 6.0.0 (з `PrivateAssets=all`, щоб не перетворювати тестовий проєкт на бібліотеку розповсюдження).
+2. У [`NetSdrClientTests.cs`](../../NetSdrClientAppTests/NetSdrClientTests.cs) додано 4 нові тести:
+ - `ChangeFrequencyAsync_SendsExactlyOneTcpMessage`
+ - `StopIQ_WhenNotConnected_DoesNotSend`
+ - `StartIQ_AfterConnect_StartsUdpListenerOnce`
+ - `StartThenStopIQ_TogglesIQStartedFlag`
+3. У [`NetSdrMessageHelperTests.cs`](../../NetSdrClientAppTests/NetSdrMessageHelperTests.cs) додано 6 нових тестів навколо `TranslateMessage` / `GetSamples`:
+ - `TranslateMessage_ControlItemRoundtrip_PreservesTypeCodeAndBody`
+ - `TranslateMessage_DataItemRoundtrip_ExtractsSequenceNumber`
+ - `GetSamples_With16BitWidth_YieldsExpectedCount`
+ - `GetSamples_With8BitWidth_TruncatesIncompleteTail`
+ - `GetSamples_OnEmptyBody_ReturnsEmptySequenceWithoutThrowing`
+ - `GetSamples_With40BitWidth_ThrowsArgumentOutOfRange`
+4. Тест `TranslateMessage_ControlItemRoundtrip_PreservesTypeCodeAndBody` викрив прихований баг у `NetSdrMessageHelper.TranslateMessage`: `Enum.IsDefined(typeof(ControlItemCodes), ushortValue)` кидав `ArgumentException`, бо underlying-тип `ControlItemCodes` — `int`. Виправлено через explicit-cast у `(int)value`.
+5. У [`.github/workflows/sonarcloud.yml`](../../.github/workflows/sonarcloud.yml) розкоментовано / переписано крок `Tests with coverage (OpenCover)`:
+ - формат `opencover`,
+ - параметр виключення `Program` (точка входу — без сенсу покривати),
+ - вихідний файл `TestResults/coverage.xml`, який підбирається `sonar.cs.opencover.reportsPaths=**/coverage.xml`.
+
+## Зміни у коді та конфігурації
+
+| Файл | Зміна |
+|------|-------|
+| [`NetSdrClientAppTests/NetSdrClientAppTests.csproj`](../../NetSdrClientAppTests/NetSdrClientAppTests.csproj) | + `coverlet.msbuild` 6.0.0 |
+| [`NetSdrClientAppTests/NetSdrClientTests.cs`](../../NetSdrClientAppTests/NetSdrClientTests.cs) | + 4 тести покриття поведінки `NetSdrClient` |
+| [`NetSdrClientAppTests/NetSdrMessageHelperTests.cs`](../../NetSdrClientAppTests/NetSdrMessageHelperTests.cs) | + 6 тестів roundtrip і граничних випадків |
+| [`NetSdrClientApp/Messages/NetSdrMessageHelper.cs`](../../NetSdrClientApp/Messages/NetSdrMessageHelper.cs) | Каст `(int)value` у `Enum.IsDefined`, щоб уникнути `ArgumentException` |
+| [`.github/workflows/sonarcloud.yml`](../../.github/workflows/sonarcloud.yml) | Активний крок `Tests with coverage` з OpenCover |
+
+## Як перевірити
+
+Локальний запуск тестів і покриття:
+
+```bash
+cd NetSdrClientAppTests
+dotnet test -c Release \
+ /p:CollectCoverage=true \
+ /p:CoverletOutput=TestResults/coverage.xml \
+ /p:CoverletOutputFormat=opencover \
+ /p:Exclude="[NetSdrClientApp]NetSdrClientApp.Program"
+```
+
+Очікуваний вивід (фактично виміряно):
+
+```
+Passed! - Failed: 0, Passed: 18, Skipped: 0, Total: 18, Duration: ~90 ms
+
++-----------------+--------+--------+--------+
+| Module | Line | Branch | Method |
++-----------------+--------+--------+--------+
+| NetSdrClientApp | 45.86% | 26.92% | 48.48% |
++-----------------+--------+--------+--------+
+```
+
+У CI Sonar тепер бачить файл `**/coverage.xml` і відображає Coverage у вкладці `Measures`.
+
+## Метрики до/після
+
+| Метрика | До | Після |
+|---------|----|-------|
+| Кількість unit-тестів | 8 | 18 |
+| Тести проходять | 8/8 | 18/18 |
+| Line coverage `NetSdrClientApp` | n/a (не вимірювалось) | 45.86% |
+| Branch coverage | n/a | 26.92% |
+| Method coverage | n/a | 48.48% |
+| Прихований баг у `TranslateMessage` (control-item) | присутній | виправлено |
+
+Низький рівень покриття зумовлений `TcpClientWrapper`/`UdpClientWrapper`, які інтегрують з ОС-сокетами і потребують рефакторингу для тестування. Це буде зроблено у Лабі 6 (а аналогічний підхід для `EchoServer` уже там запланований). До тих пір `NetSdrClient`/`NetSdrMessageHelper` (бізнес-логіка) покриті значно краще.
+
+## Висновки
+
+Додавання `coverlet.msbuild` та 10 нових тестів дало вимірюване покриття у Sonar і одразу викрило прихований баг в існуючому коді `TranslateMessage`. Це показовий приклад того, як unit-тести працюють як «детектор регресій» у спадковому коді.
+
+## Посилання
+
+- [Coverlet — налаштування MSBuild](https://github.com/coverlet-coverage/coverlet/blob/master/Documentation/MSBuildIntegration.md)
+- [SonarCloud — `sonar.cs.opencover.reportsPaths`](https://docs.sonarsource.com/sonarcloud/enriching/test-coverage/dotnet-test-coverage/)
+
+## Скріни
+
+```text
+[ScreenSonar6] Sonar Measures — Coverage on New Code
+[ScreenSonar7] Sonar Activity — графік росту Coverage
+[ScreenAction1] CI лог кроку Tests with coverage у GitHub Actions
+```
diff --git a/docs/labs/lab-04.md b/docs/labs/lab-04.md
new file mode 100644
index 0000000..dc95445
--- /dev/null
+++ b/docs/labs/lab-04.md
@@ -0,0 +1,101 @@
+# Лабораторна робота 4. Дублікати через SonarCloud
+
+**Дисципліна**: Реінжиніринг програмного забезпечення
+**Студент**: Биков Нікіта Вячеславович
+**Група**: ПЗС-1
+**Гілка**: `lab-04-duplications`
+**Pull Request**: створюється з `nik-bykoff:lab-04-duplications` у `lenagrin/ReengineeringCourse:master`
+
+## Мета
+
+Зменшити дублікати коду до рівня, нижчого за поріг SonarCloud (Duplications on New Code не більше 3%) шляхом виокремлення спільних блоків у окремі helper-методи/класи.
+
+## Виявлені дублікати (до)
+
+1. У [`TcpClientWrapper.cs`](../../NetSdrClientApp/Networking/TcpClientWrapper.cs) перевантажені `SendMessageAsync(byte[])` та `SendMessageAsync(string)` — обидві повторювали 6 рядків (перевірка стану, лог, `WriteAsync`). Це класичний `S4144` / Sonar duplication block.
+2. У [`UdpClientWrapper.cs`](../../NetSdrClientApp/Networking/UdpClientWrapper.cs) методи `StopListening` та `Exit` мали повністю однакові тіла (cancel + close + log + catch).
+3. Hex-форматування `data.Select(b => Convert.ToString(b, toBase: 16))` було продубльоване у трьох місцях (TCP send, TCP receive log, UDP samples log) у `TcpClientWrapper.cs` та `NetSdrClient.cs`.
+
+## Виконані зміни
+
+### 1. SendMessageAsync overloads -> SendCoreAsync
+
+Винесено приватний метод [`TcpClientWrapper.SendCoreAsync`](../../NetSdrClientApp/Networking/TcpClientWrapper.cs):
+
+```csharp
+public Task SendMessageAsync(byte[] data) => SendCoreAsync(data);
+public Task SendMessageAsync(string str) => SendCoreAsync(Encoding.UTF8.GetBytes(str));
+
+private async Task SendCoreAsync(byte[] data)
+{
+ if (!Connected || _stream is null || !_stream.CanWrite)
+ {
+ throw new InvalidOperationException("Not connected to a server.");
+ }
+ Console.WriteLine("Message sent: " + HexFormatter.ToSpaceSeparatedHex(data));
+ await _stream.WriteAsync(data, 0, data.Length);
+}
+```
+
+### 2. StopListening / Exit -> StopCore
+
+Обидва публічних методи делегують у приватний [`UdpClientWrapper.StopCore`](../../NetSdrClientApp/Networking/UdpClientWrapper.cs):
+
+```csharp
+public void StopListening() => StopCore();
+public void Exit() => StopCore();
+
+private void StopCore() { /* спільне тіло */ }
+```
+
+Контракт `IUdpClient` не змінився, тому існуючі тести не модифіковано.
+
+### 3. Hex-формат -> HexFormatter
+
+Створено [`NetSdrClientApp/Networking/HexFormatter.cs`](../../NetSdrClientApp/Networking/HexFormatter.cs) як `internal static`-helper із одним методом `ToSpaceSeparatedHex(byte[])`. Ним користуються:
+- `TcpClientWrapper.SendCoreAsync` — лог відправлення;
+- `NetSdrClient._udpClient_MessageReceived` — лог samples;
+- `NetSdrClient._tcpClient_MessageReceived` — лог response.
+
+## Як перевірити
+
+```bash
+dotnet build NetSdrClient.sln -c Release --no-restore
+dotnet test NetSdrClient.sln -c Release --no-build
+```
+
+Очікуваний результат: build OK, 18/18 тестів passed (без змін у тестовій логіці).
+
+У SonarCloud: вкладка `Measures -> Duplications` має показати 0% `Duplications on New Code` (PR-сторінка) і Quality Gate на цьому пункті — зелений.
+
+## Метрики до/після
+
+| Метрика | До | Після |
+|---------|----|-------|
+| Дублікат-блок `SendMessageAsync` | 1 пара (~12 LOC) | 0 |
+| Дублікат-блок `StopListening` / `Exit` | 1 пара (~10 LOC) | 0 |
+| Дублікати hex-логування | 3 копії | 1 helper |
+| Тести проходять | 18/18 | 18/18 |
+
+Очікувано у Sonar:
+
+| Sonar метрика | До | Після (очікується) |
+|---------------|----|---------------------|
+| `Duplications on New Code` | >3% | 0% |
+| `Duplicated Lines` (NetSdrClientApp) | ~25 | 0 |
+
+## Висновки
+
+Дрібні дублікати в `Networking`-шарі усунено через стандартну техніку Extract Method + Extract Class. Контракт публічних API не змінився, поведінка ідентична, тести залишаються 18/18 зеленими. Тепер у `NetSdrClientApp` нема жодного очевидного дубль-блоку.
+
+## Посилання
+
+- [SonarSource — `S4144` methods should not have identical implementations](https://rules.sonarsource.com/csharp/RSPEC-4144)
+- [SonarCloud — Duplications](https://docs.sonarsource.com/sonarcloud/digging-deeper/duplications/)
+
+## Скріни
+
+```text
+[ScreenSonar8] Sonar Duplications до — більший за 3% на New Code
+[ScreenSonar9] Sonar Duplications після — 0% на цьому PR
+```
diff --git a/docs/labs/lab-05.md b/docs/labs/lab-05.md
new file mode 100644
index 0000000..1e28f97
--- /dev/null
+++ b/docs/labs/lab-05.md
@@ -0,0 +1,109 @@
+# Лабораторна робота 5. Архітектурні правила (NetArchTest)
+
+**Дисципліна**: Реінжиніринг програмного забезпечення
+**Студент**: Биков Нікіта Вячеславович
+**Група**: ПЗС-1
+**Гілка**: `lab-05-arch-rules`
+**Pull Request**: створюється з `nik-bykoff:lab-05-arch-rules` у `lenagrin/ReengineeringCourse:master`
+
+## Мета
+
+Перевести «гарну архітектурну поведінку» з рівня домовленостей у рівень виконуваних тестів: додати незалежний проєкт `NetSdrClient.ArchTests` із бібліотекою [`NetArchTest.Rules`](https://github.com/BenMorris/NetArchTest), кодувати правила залежностей як NUnit-тести і таким чином інтегрувати їх у Quality Gate.
+
+## Хід виконання
+
+PR умисно складається з двох комітів — щоб у git-історії було видно артефакт «red CI -> green CI», який і є метою лаби.
+
+```mermaid
+flowchart LR
+ c1["Commit 1: Lab 5 (red)"] --> arch["+NetSdrClient.ArchTests with 4 rules"] --> red["CI: 1 of 4 ArchTests fails"]
+ c2["Commit 2: Lab 5 (green)"] --> fixes["seal wrappers and put UdpClientWrapper into NetSdrClientApp.Networking"] --> green["CI: 4 of 4 ArchTests pass"]
+```
+
+### Архітектурні правила, що кодуються у [`NetSdrClient.ArchTests/ArchitectureTests.cs`](../../NetSdrClient.ArchTests/ArchitectureTests.cs)
+
+1. `Messages_ShouldNotDependOn_Networking` — тип з `NetSdrClientApp.Messages` не повинен використовувати жоден тип з `NetSdrClientApp.Networking`. Гарантує, що бізнес-домен (формат повідомлень) не «зливається» з транспортом.
+2. `Networking_ShouldNotDependOn_Messages` — симетрично: транспорт не «втягує» домен. Це фактично проголошує одностороннє підпорядкування у бік верхнього рівня (`NetSdrClient` оркеструє обидва).
+3. `Interfaces_InNetworking_ShouldStartWithI` — конвенція іменування `I*` для інтерфейсів. Захищає від дрейфу стилю.
+4. `NetworkingWrappers_ShouldBeSealed` — типи з суфіксом `Wrapper` мають бути `sealed`. Чітко комунікує, що такі класи — кінцева імплементація транспорту, не призначені для наслідування.
+
+### Червоний прогін (commit 1)
+
+При запуску `dotnet test NetSdrClient.ArchTests` правило 4 вкаже на `NetSdrClientApp.Networking.TcpClientWrapper`:
+
+```text
+Failed NetworkingWrappers_ShouldBeSealed
+ Wrapper classes in Networking namespace should be sealed to prevent unintended subclassing.
+ Failing types: NetSdrClientApp.Networking.TcpClientWrapper
+```
+
+`UdpClientWrapper` у цей момент ще не сканується, бо знаходиться у глобальному namespace (це окремий smell, який також буде виправлено).
+
+### Зелений прогін (commit 2)
+
+У комiті `Lab 5 (green)` зроблено:
+
+- [`TcpClientWrapper`](../../NetSdrClientApp/Networking/TcpClientWrapper.cs) — позначено як `sealed`.
+- [`UdpClientWrapper`](../../NetSdrClientApp/Networking/UdpClientWrapper.cs) — перенесено у namespace `NetSdrClientApp.Networking` і також позначено `sealed`.
+- [`IUdpClient`](../../NetSdrClientApp/Networking/IUdpClient.cs) — також перенесено у namespace `NetSdrClientApp.Networking` для консистентності з `ITcpClient`.
+
+Після фіксу прогін зелений: `Failed: 0, Passed: 4`. Існуючі 18 тестів проєкту `NetSdrClientAppTests` залишаються 18/18 зеленими, бо `using NetSdrClientApp.Networking;` у `Program.cs` тепер коректно резолвить обидва типи.
+
+## Зміни у коді та конфігурації
+
+| Файл | Зміна |
+|------|-------|
+| [`NetSdrClient.sln`](../../NetSdrClient.sln) | + проєкт `NetSdrClient.ArchTests` |
+| [`NetSdrClient.ArchTests/NetSdrClient.ArchTests.csproj`](../../NetSdrClient.ArchTests/NetSdrClient.ArchTests.csproj) | новий проєкт з пакетом `NetArchTest.Rules` 1.3.2 |
+| [`NetSdrClient.ArchTests/ArchitectureTests.cs`](../../NetSdrClient.ArchTests/ArchitectureTests.cs) | 4 архітектурні правила |
+| [`NetSdrClientApp/Networking/TcpClientWrapper.cs`](../../NetSdrClientApp/Networking/TcpClientWrapper.cs) | `public class` -> `public sealed class` |
+| [`NetSdrClientApp/Networking/UdpClientWrapper.cs`](../../NetSdrClientApp/Networking/UdpClientWrapper.cs) | додано `namespace NetSdrClientApp.Networking`, тип `sealed` |
+| [`NetSdrClientApp/Networking/IUdpClient.cs`](../../NetSdrClientApp/Networking/IUdpClient.cs) | додано `namespace NetSdrClientApp.Networking` |
+
+## Як перевірити
+
+```bash
+# Прогін лише архітектурних тестів
+dotnet test NetSdrClient.ArchTests/NetSdrClient.ArchTests.csproj -c Release
+
+# Усе разом
+dotnet test NetSdrClient.sln -c Release
+```
+
+Очікуваний вивід після обох комітів:
+
+```text
+Passed! - Failed: 0, Passed: 18, ... - NetSdrClientAppTests.dll
+Passed! - Failed: 0, Passed: 4, ... - NetSdrClient.ArchTests.dll
+```
+
+Якщо хтось у майбутньому випадково:
+- додасть `using NetSdrClientApp.Networking;` у файл усередині `Messages` -> правило 1 заблокує;
+- зробить новий wrapper-клас не `sealed` -> правило 4 заблокує;
+- назве інтерфейс без `I` префіксу -> правило 3 заблокує.
+
+## Метрики до/після
+
+| Метрика | До commit 1 | Після commit 1 (red) | Після commit 2 (green) |
+|---------|-------------|----------------------|------------------------|
+| Архітектурних тестів усього | 0 | 4 | 4 |
+| Failed | n/a | 1 | 0 |
+| Wrapper-класи `sealed` | 0 з 2 | 0 з 2 | 2 з 2 |
+| Типи `Networking` поза namespace | 2 (`UdpClientWrapper`, `IUdpClient`) | 2 | 0 |
+
+## Висновки
+
+NetArchTest перетворює архітектурні домовленості на тести: вони запускаються разом із юніт-тестами і блокують злиття через CI/Quality Gate. Демо-коміт із червоним станом фіксує, що тести реально ловлять порушення, а не тільки красиво виглядають на папері.
+
+## Посилання
+
+- [BenMorris/NetArchTest — README](https://github.com/BenMorris/NetArchTest)
+- [Pluralsight — Architecture testing in .NET](https://www.pluralsight.com/courses/architecture-testing-applications-net)
+
+## Скріни
+
+```text
+[ScreenAction2] Actions run для commit 1 — червоний (1 архтест fail)
+[ScreenAction3] Actions run для commit 2 — зелений (4/4 pass)
+[ScreenSonar10] Sonar Activity — чотири нові тести у звіті
+```