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/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/TcpClientWrapper.cs b/NetSdrClientApp/Networking/TcpClientWrapper.cs index 1f37e2e..a3a8fd5 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; @@ -12,11 +8,11 @@ namespace NetSdrClientApp.Networking { public class TcpClientWrapper : ITcpClient { - private string _host; - private int _port; + private readonly string _host; + private readonly int _port; private TcpClient? _tcpClient; private NetworkStream? _stream; - private CancellationTokenSource _cts; + private CancellationTokenSource? _cts; public bool Connected => _tcpClient != null && _tcpClient.Connected && _stream != null; @@ -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..366d590 100644 --- a/NetSdrClientApp/Networking/UdpClientWrapper.cs +++ b/NetSdrClientApp/Networking/UdpClientWrapper.cs @@ -1,10 +1,9 @@ using System; using System.Net; using System.Net.Sockets; -using System.Security.Cryptography; -using System.Text; using System.Threading; using System.Threading.Tasks; +using NetSdrClientApp.Networking; public class UdpClientWrapper : IUdpClient { @@ -35,9 +34,9 @@ public async Task StartListeningAsync() Console.WriteLine($"Received from {result.RemoteEndPoint}"); } } - catch (OperationCanceledException ex) + catch (OperationCanceledException) { - //empty + // graceful shutdown initiated by StopListening()/Exit() } catch (Exception ex) { @@ -45,21 +44,11 @@ public async Task StartListeningAsync() } } - 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() => StopCore(); - public void Exit() + private void StopCore() { try { @@ -75,11 +64,16 @@ public void Exit() public override int GetHashCode() { - var payload = $"{nameof(UdpClientWrapper)}|{_localEndPoint.Address}|{_localEndPoint.Port}"; + return HashCode.Combine(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×) -[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=ppanchen_NetSdrClient&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient) -[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=ppanchen_NetSdrClient&metric=coverage)](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient) -[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=ppanchen_NetSdrClient&metric=bugs)](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient) -[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=ppanchen_NetSdrClient&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient) -[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=ppanchen_NetSdrClient&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient) -[![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=ppanchen_NetSdrClient&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient) -[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=ppanchen_NetSdrClient&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient) -[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=ppanchen_NetSdrClient&metric=sqale_rating)](https://sonarcloud.io/summary/new_code?id=ppanchen_NetSdrClient) +**Предмет**: Реінжиніринг програмного забезпечення +**Студент**: Биков Нікіта Вячеславович +**Група**: ПЗС-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`): + +[![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=nik-bykoff_ReengineeringCourse&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=nik-bykoff_ReengineeringCourse) +[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=nik-bykoff_ReengineeringCourse&metric=coverage)](https://sonarcloud.io/summary/new_code?id=nik-bykoff_ReengineeringCourse) +[![Bugs](https://sonarcloud.io/api/project_badges/measure?project=nik-bykoff_ReengineeringCourse&metric=bugs)](https://sonarcloud.io/summary/new_code?id=nik-bykoff_ReengineeringCourse) +[![Code Smells](https://sonarcloud.io/api/project_badges/measure?project=nik-bykoff_ReengineeringCourse&metric=code_smells)](https://sonarcloud.io/summary/new_code?id=nik-bykoff_ReengineeringCourse) +[![Vulnerabilities](https://sonarcloud.io/api/project_badges/measure?project=nik-bykoff_ReengineeringCourse&metric=vulnerabilities)](https://sonarcloud.io/summary/new_code?id=nik-bykoff_ReengineeringCourse) +[![Duplicated Lines (%)](https://sonarcloud.io/api/project_badges/measure?project=nik-bykoff_ReengineeringCourse&metric=duplicated_lines_density)](https://sonarcloud.io/summary/new_code?id=nik-bykoff_ReengineeringCourse) +[![Security Rating](https://sonarcloud.io/api/project_badges/measure?project=nik-bykoff_ReengineeringCourse&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=nik-bykoff_ReengineeringCourse) +[![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=nik-bykoff_ReengineeringCourse&metric=sqale_rating)](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 +```