From edde11a26b0a9defab4deff59bd41c4a547b9102 Mon Sep 17 00:00:00 2001 From: nik-bykoff Date: Mon, 4 May 2026 21:25:28 +0300 Subject: [PATCH 1/7] Lab 1: SonarCloud + GitHub Actions CI bootstrap - Move SONAR_PROJECT_KEY/SONAR_ORGANIZATION into workflow env block with explicit setup instructions for fork-based usage. - Update README with student header, fork/upstream links and a table of all eight lab reports. - Replace SonarCloud badges with placeholders for nik-bykoff fork. - Add docs/labs/lab-01.md report describing manual SonarCloud steps, CI workflow layout and verification checklist. - Ignore local .env files to prevent accidental token leaks. --- .github/workflows/sonarcloud.yml | 80 ++++++++++++++------------------ .gitignore | 7 ++- README.md | 39 ++++++++++++---- docs/labs/lab-01.md | 74 +++++++++++++++++++++++++++++ 4 files changed, 145 insertions(+), 55 deletions(-) create mode 100644 docs/labs/lab-01.md diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index e784069..b298fd3 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 + + # Крок з покриттям буде увімкнено у Лабі 3 + # - name: Tests with coverage (OpenCover) + # run: | + # dotnet test NetSdrClientAppTests/NetSdrClientAppTests.csproj -c Release --no-build ` + # /p:CollectCoverage=true ` + # /p:CoverletOutput=TestResults/coverage.xml ` + # /p:CoverletOutputFormat=opencover + # shell: pwsh + - name: 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/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 з реальними значеннями +``` From 0f442d1cd3c7543ad8dd30095a81a5262805c52b Mon Sep 17 00:00:00 2001 From: nik-bykoff Date: Mon, 4 May 2026 21:40:56 +0300 Subject: [PATCH 2/7] Lab 2: fix code smells and bugs in NetSdrClientApp - Rename NetSdrClient.Disconect to Disconnect (typo) and update callers/tests. - Remove stray semicolon in StartIQAsync and unused JSType using-statics. - Make responseTaskSource thread-safe via Interlocked.Exchange + TrySetResult. - Replace Aggregate-on-empty-may-throw log formatting with string.Join in three places. - Rewrite GetSamples from O(n^2) IEnumerable.Count loop to O(n) for-loop with reusable byte[4] buffer. - Replace MD5-based UdpClientWrapper.GetHashCode with HashCode.Combine and add Equals override. - Make TcpClientWrapper._cts nullable, mark _host/_port readonly, drop unused exception variable names. - All eight existing unit tests still pass; compiler warnings dropped from 18 to 15 (rest are EchoTcpServer or NuGet). --- .../Messages/NetSdrMessageHelper.cs | 27 +++---- NetSdrClientApp/NetSdrClient.cs | 43 ++++------ NetSdrClientApp/Networking/ITcpClient.cs | 4 - .../Networking/TcpClientWrapper.cs | 18 ++--- .../Networking/UdpClientWrapper.cs | 21 ++--- NetSdrClientApp/Program.cs | 2 +- NetSdrClientAppTests/NetSdrClientTests.cs | 6 +- docs/labs/lab-02.md | 78 +++++++++++++++++++ 8 files changed, 132 insertions(+), 67 deletions(-) create mode 100644 docs/labs/lab-02.md diff --git a/NetSdrClientApp/Messages/NetSdrMessageHelper.cs b/NetSdrClientApp/Messages/NetSdrMessageHelper.cs index 0d69b4d..7340dca 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 { @@ -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..c11d4d9 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: " + string.Join(" ", body.Select(b => Convert.ToString(b, toBase: 16)))); 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: " + string.Join(" ", e.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..18b4952 100644 --- a/NetSdrClientApp/Networking/TcpClientWrapper.cs +++ b/NetSdrClientApp/Networking/TcpClientWrapper.cs @@ -12,11 +12,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; @@ -75,7 +75,7 @@ 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}")); + Console.WriteLine("Message sent: " + string.Join(" ", data.Select(b => Convert.ToString(b, toBase: 16)))); await _stream.WriteAsync(data, 0, data.Length); } else @@ -89,7 +89,7 @@ public async Task SendMessageAsync(string str) var data = Encoding.UTF8.GetBytes(str); if (Connected && _stream != null && _stream.CanWrite) { - Console.WriteLine($"Message sent: " + data.Select(b => Convert.ToString(b, toBase: 16)).Aggregate((l, r) => $"{l} {r}")); + Console.WriteLine("Message sent: " + string.Join(" ", data.Select(b => Convert.ToString(b, toBase: 16)))); await _stream.WriteAsync(data, 0, data.Length); } else @@ -100,11 +100,11 @@ public async Task SendMessageAsync(string str) 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 +117,9 @@ private async Task StartListeningAsync() } } } - catch (OperationCanceledException ex) + catch (OperationCanceledException) { - //empty + // graceful shutdown initiated by Disconnect() } catch (Exception ex) { diff --git a/NetSdrClientApp/Networking/UdpClientWrapper.cs b/NetSdrClientApp/Networking/UdpClientWrapper.cs index 31e0b79..e0302fe 100644 --- a/NetSdrClientApp/Networking/UdpClientWrapper.cs +++ b/NetSdrClientApp/Networking/UdpClientWrapper.cs @@ -1,8 +1,6 @@ using System; using System.Net; using System.Net.Sockets; -using System.Security.Cryptography; -using System.Text; using System.Threading; using System.Threading.Tasks; @@ -35,9 +33,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) { @@ -75,11 +73,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/NetSdrClientTests.cs b/NetSdrClientAppTests/NetSdrClientTests.cs index ad00c4f..4778993 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 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) +``` From 9a04117828e9a21ead3a5f60a8d86b5517c7bec6 Mon Sep 17 00:00:00 2001 From: nik-bykoff Date: Mon, 4 May 2026 21:45:21 +0300 Subject: [PATCH 3/7] Lab 3: add coverlet coverage reporting and ten new unit tests - Add coverlet.msbuild to NetSdrClientAppTests with PrivateAssets=all. - Add four NetSdrClient tests (ChangeFrequency, idempotent IQ flag, listener startup, no-op when disconnected). - Add six NetSdrMessageHelper tests covering control-item/data-item roundtrip and GetSamples edge cases (8/16 bit, empty body, oversized width). - Fix latent bug in TranslateMessage where Enum.IsDefined was called with ushort while ControlItemCodes underlying type is int. - Re-enable Tests with coverage step in CI workflow with opencover output and Program exclusion. - 18/18 tests passing locally; coverage of NetSdrClientApp module: line 45.86%, branch 26.92%, method 48.48%. --- .github/workflows/sonarcloud.yml | 16 +-- .../Messages/NetSdrMessageHelper.cs | 2 +- .../NetSdrClientAppTests.csproj | 4 + NetSdrClientAppTests/NetSdrClientTests.cs | 44 ++++++++- .../NetSdrMessageHelperTests.cs | 92 ++++++++++++++++- docs/labs/lab-03.md | 99 +++++++++++++++++++ 6 files changed, 246 insertions(+), 11 deletions(-) create mode 100644 docs/labs/lab-03.md diff --git a/.github/workflows/sonarcloud.yml b/.github/workflows/sonarcloud.yml index b298fd3..0ae8897 100644 --- a/.github/workflows/sonarcloud.yml +++ b/.github/workflows/sonarcloud.yml @@ -57,14 +57,14 @@ jobs: - name: Build run: dotnet build NetSdrClient.sln -c Release --no-restore - # Крок з покриттям буде увімкнено у Лабі 3 - # - name: Tests with coverage (OpenCover) - # run: | - # dotnet test NetSdrClientAppTests/NetSdrClientAppTests.csproj -c Release --no-build ` - # /p:CollectCoverage=true ` - # /p:CoverletOutput=TestResults/coverage.xml ` - # /p:CoverletOutputFormat=opencover - # shell: pwsh + - name: Tests with coverage (OpenCover) + run: | + dotnet test NetSdrClientAppTests/NetSdrClientAppTests.csproj -c Release --no-build ` + /p:CollectCoverage=true ` + /p:CoverletOutput=TestResults/coverage.xml ` + /p:CoverletOutputFormat=opencover ` + /p:Exclude="[NetSdrClientApp]NetSdrClientApp.Program" + shell: pwsh - name: SonarScanner End run: dotnet sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" diff --git a/NetSdrClientApp/Messages/NetSdrMessageHelper.cs b/NetSdrClientApp/Messages/NetSdrMessageHelper.cs index 7340dca..edb6692 100644 --- a/NetSdrClientApp/Messages/NetSdrMessageHelper.cs +++ b/NetSdrClientApp/Messages/NetSdrMessageHelper.cs @@ -80,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; } 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 4778993..6c5e012 100644 --- a/NetSdrClientAppTests/NetSdrClientTests.cs +++ b/NetSdrClientAppTests/NetSdrClientTests.cs @@ -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/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 +``` From 8a4e4310b8e80f7737c8362c368abb2cb61f1361 Mon Sep 17 00:00:00 2001 From: nik-bykoff Date: Mon, 4 May 2026 21:49:55 +0300 Subject: [PATCH 4/7] Lab 4: extract duplicated send and stop helpers - Add internal HexFormatter.ToSpaceSeparatedHex used by TCP send log, TCP receive log and UDP samples log. - Replace TcpClientWrapper.SendMessageAsync(byte[]) and SendMessageAsync(string) overloads with thin wrappers around private SendCoreAsync(byte[]). - Collapse identical UdpClientWrapper.StopListening and Exit into StopCore via thin delegating wrappers. - 18/18 tests still pass; behavior unchanged. --- NetSdrClientApp/NetSdrClient.cs | 4 +- NetSdrClientApp/Networking/HexFormatter.cs | 18 ++++ .../Networking/TcpClientWrapper.cs | 33 ++---- .../Networking/UdpClientWrapper.cs | 19 +--- docs/labs/lab-04.md | 101 ++++++++++++++++++ 5 files changed, 134 insertions(+), 41 deletions(-) create mode 100644 NetSdrClientApp/Networking/HexFormatter.cs create mode 100644 docs/labs/lab-04.md diff --git a/NetSdrClientApp/NetSdrClient.cs b/NetSdrClientApp/NetSdrClient.cs index c11d4d9..4c8ae6e 100644 --- a/NetSdrClientApp/NetSdrClient.cs +++ b/NetSdrClientApp/NetSdrClient.cs @@ -117,7 +117,7 @@ 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: " + string.Join(" ", body.Select(b => Convert.ToString(b, toBase: 16)))); + 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)) @@ -150,7 +150,7 @@ private void _tcpClient_MessageReceived(object? sender, byte[] e) var tcs = Interlocked.Exchange(ref _responseTaskSource, null); tcs?.TrySetResult(e); - Console.WriteLine("Response recieved: " + string.Join(" ", e.Select(b => Convert.ToString(b, toBase: 16)))); + 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/TcpClientWrapper.cs b/NetSdrClientApp/Networking/TcpClientWrapper.cs index 18b4952..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; @@ -71,31 +67,19 @@ public void Disconnect() } } - public async Task SendMessageAsync(byte[] data) - { - if (Connected && _stream != null && _stream.CanWrite) - { - Console.WriteLine("Message sent: " + string.Join(" ", data.Select(b => Convert.ToString(b, toBase: 16)))); - 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: " + string.Join(" ", data.Select(b => Convert.ToString(b, toBase: 16)))); - 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() @@ -136,5 +120,4 @@ private async Task StartListeningAsync() } } } - } diff --git a/NetSdrClientApp/Networking/UdpClientWrapper.cs b/NetSdrClientApp/Networking/UdpClientWrapper.cs index e0302fe..366d590 100644 --- a/NetSdrClientApp/Networking/UdpClientWrapper.cs +++ b/NetSdrClientApp/Networking/UdpClientWrapper.cs @@ -3,6 +3,7 @@ using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; +using NetSdrClientApp.Networking; public class UdpClientWrapper : IUdpClient { @@ -43,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 { 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 +``` From 0872fec68b2e12df4ceef9e1ad305e822e9f7f6a Mon Sep 17 00:00:00 2001 From: nik-bykoff Date: Mon, 4 May 2026 21:54:14 +0300 Subject: [PATCH 5/7] Lab 5 (red): introduce NetArchTest architecture rules - Add NetSdrClient.ArchTests project (NUnit + NetArchTest.Rules 1.3.2) with reference to NetSdrClientApp. - Encode four architecture rules: * Messages namespace must not depend on Networking. * Networking namespace must not depend on Messages. * Interfaces in Networking namespace start with I. * Wrapper classes in Networking namespace are sealed. - Run is intentionally red on this commit (TcpClientWrapper is not sealed) to demonstrate ArchTest catching a real violation; the green fix follows in the next commit. --- NetSdrClient.ArchTests/ArchitectureTests.cs | 82 +++++++++++++++++++ .../NetSdrClient.ArchTests.csproj | 29 +++++++ NetSdrClient.sln | 6 ++ 3 files changed, 117 insertions(+) create mode 100644 NetSdrClient.ArchTests/ArchitectureTests.cs create mode 100644 NetSdrClient.ArchTests/NetSdrClient.ArchTests.csproj 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 From a8f0bc3319e114db3c5d11317ba7d975a3cec476 Mon Sep 17 00:00:00 2001 From: nik-bykoff Date: Mon, 4 May 2026 21:56:54 +0300 Subject: [PATCH 6/7] Lab 5 (green): fix violations and stabilize NetArchTest suite - Mark TcpClientWrapper and UdpClientWrapper as sealed to satisfy NetworkingWrappers_ShouldBeSealed. - Move UdpClientWrapper and IUdpClient into the NetSdrClientApp.Networking namespace so they are scoped by the architectural rules. - All four ArchTests now pass; existing 18 unit tests remain green. - Adds docs/labs/lab-05.md describing the red-then-green CI demo. --- NetSdrClientApp/Networking/IUdpClient.cs | 20 ++-- .../Networking/TcpClientWrapper.cs | 2 +- .../Networking/UdpClientWrapper.cs | 106 ++++++++--------- docs/labs/lab-05.md | 109 ++++++++++++++++++ 4 files changed, 177 insertions(+), 60 deletions(-) create mode 100644 docs/labs/lab-05.md 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 a3a8fd5..f2ce43e 100644 --- a/NetSdrClientApp/Networking/TcpClientWrapper.cs +++ b/NetSdrClientApp/Networking/TcpClientWrapper.cs @@ -6,7 +6,7 @@ namespace NetSdrClientApp.Networking { - public class TcpClientWrapper : ITcpClient + public sealed class TcpClientWrapper : ITcpClient { private readonly string _host; private readonly int _port; diff --git a/NetSdrClientApp/Networking/UdpClientWrapper.cs b/NetSdrClientApp/Networking/UdpClientWrapper.cs index 366d590..6f00536 100644 --- a/NetSdrClientApp/Networking/UdpClientWrapper.cs +++ b/NetSdrClientApp/Networking/UdpClientWrapper.cs @@ -3,77 +3,79 @@ using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; -using NetSdrClientApp.Networking; -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; - try + public UdpClientWrapper(int port) { - _udpClient = new UdpClient(_localEndPoint); - while (!_cts.Token.IsCancellationRequested) + _localEndPoint = new IPEndPoint(IPAddress.Any, port); + } + + public async Task StartListeningAsync() + { + _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) - { - // graceful shutdown initiated by StopListening()/Exit() - } - catch (Exception ex) - { - Console.WriteLine($"Error receiving message: {ex.Message}"); - } - } - public void StopListening() => StopCore(); + public void StopListening() => StopCore(); - public void Exit() => StopCore(); + public void Exit() => StopCore(); - private void StopCore() - { - try + 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() - { - return HashCode.Combine(nameof(UdpClientWrapper), _localEndPoint.Address, _localEndPoint.Port); - } - - public override bool Equals(object? obj) - { - if (obj is not UdpClientWrapper other) + public override bool Equals(object? obj) { - return false; - } + if (obj is not UdpClientWrapper other) + { + return false; + } - return _localEndPoint.Equals(other._localEndPoint); + return _localEndPoint.Equals(other._localEndPoint); + } } } 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 — чотири нові тести у звіті +``` From f29fb9c1e7104cd288003f18d40cfc43d51a9885 Mon Sep 17 00:00:00 2001 From: nik-bykoff Date: Mon, 4 May 2026 22:01:29 +0300 Subject: [PATCH 7/7] Lab 6: split EchoTcpServer for testability and add unit tests - Extract pure echo algorithm into EchoCore.EchoLoopAsync(Stream, CancellationToken, int) so it can be exercised against an in-memory Stream. - Introduce IEchoServer contract; mark EchoServer sealed and make Stop() idempotent. - Move UdpTimedSender into its own file with namespace, argument validation (null host, non-positive interval), an internal UdpClient seam and an IsRunning flag. - Reduce Program.cs to a thin Main composition root. - Add new EchoTcpServer.Tests project (NUnit + Moq + coverlet.msbuild) with ten unit tests covering EchoCore and UdpTimedSender lifecycles and validation. - 18+4+10 = 32 tests pass overall; EchoServer module coverage rises from 0% to line 55.12% / branch 65.38% / method 61.53%; project compiler warnings drop from 5 to 0. --- EchoTcpServer.Tests/EchoCoreTests.cs | 127 +++++++++++++ .../EchoTcpServer.Tests.csproj | 33 ++++ EchoTcpServer.Tests/UdpTimedSenderTests.cs | 64 +++++++ EchoTcpServer/EchoCore.cs | 31 ++++ EchoTcpServer/EchoServer.cs | 79 ++++++++ EchoTcpServer/IEchoServer.cs | 11 ++ EchoTcpServer/Program.cs | 174 ++---------------- EchoTcpServer/UdpTimedSender.cs | 82 +++++++++ NetSdrClient.sln | 6 + docs/labs/lab-06.md | 130 +++++++++++++ 10 files changed, 580 insertions(+), 157 deletions(-) create mode 100644 EchoTcpServer.Tests/EchoCoreTests.cs create mode 100644 EchoTcpServer.Tests/EchoTcpServer.Tests.csproj create mode 100644 EchoTcpServer.Tests/UdpTimedSenderTests.cs create mode 100644 EchoTcpServer/EchoCore.cs create mode 100644 EchoTcpServer/EchoServer.cs create mode 100644 EchoTcpServer/IEchoServer.cs create mode 100644 EchoTcpServer/UdpTimedSender.cs create mode 100644 docs/labs/lab-06.md diff --git a/EchoTcpServer.Tests/EchoCoreTests.cs b/EchoTcpServer.Tests/EchoCoreTests.cs new file mode 100644 index 0000000..7272fc3 --- /dev/null +++ b/EchoTcpServer.Tests/EchoCoreTests.cs @@ -0,0 +1,127 @@ +using System; +using System.IO; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using EchoTcpServer; +using NUnit.Framework; + +namespace EchoTcpServerTests +{ + public class EchoCoreTests + { + [Test] + public async Task EchoLoopAsync_OnPlainPayload_WritesIdenticalBytesBack() + { + var inbound = Encoding.UTF8.GetBytes("hello world"); + var input = new MemoryStream(inbound); + var output = new MemoryStream(); + using var bridge = new BridgeStream(input, output); + + long total = await EchoCore.EchoLoopAsync(bridge, CancellationToken.None); + + Assert.Multiple(() => + { + Assert.That(total, Is.EqualTo(inbound.Length)); + Assert.That(output.ToArray(), Is.EqualTo(inbound)); + }); + } + + [Test] + public async Task EchoLoopAsync_StopsWhenStreamClosed() + { + var input = new MemoryStream(Array.Empty()); + var output = new MemoryStream(); + using var bridge = new BridgeStream(input, output); + + long total = await EchoCore.EchoLoopAsync(bridge, CancellationToken.None); + + Assert.That(total, Is.EqualTo(0)); + Assert.That(output.Length, Is.EqualTo(0)); + } + + [Test] + public void EchoLoopAsync_RespectsCancellationToken() + { + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var input = new MemoryStream(new byte[] { 1, 2, 3 }); + var output = new MemoryStream(); + using var bridge = new BridgeStream(input, output); + + Assert.DoesNotThrowAsync(async () => + { + await EchoCore.EchoLoopAsync(bridge, cts.Token); + }); + + Assert.That(output.Length, Is.EqualTo(0), + "Pre-cancelled token must short-circuit the loop before any echo happens."); + } + + [Test] + public void EchoLoopAsync_NullStream_Throws() + { + Assert.ThrowsAsync( + async () => await EchoCore.EchoLoopAsync(null!, CancellationToken.None)); + } + + [Test] + public void EchoLoopAsync_NonPositiveBufferSize_Throws() + { + using var input = new MemoryStream(); + using var output = new MemoryStream(); + using var bridge = new BridgeStream(input, output); + + Assert.ThrowsAsync( + async () => await EchoCore.EchoLoopAsync(bridge, CancellationToken.None, bufferSize: 0)); + } + + /// + /// Couples a read-only input stream with a write-only output stream so the + /// echo loop can be exercised without real sockets. + /// + private sealed class BridgeStream : Stream + { + private readonly Stream _input; + private readonly Stream _output; + + public BridgeStream(Stream input, Stream output) + { + _input = input; + _output = output; + } + + public override bool CanRead => _input.CanRead; + public override bool CanSeek => false; + public override bool CanWrite => _output.CanWrite; + public override long Length => throw new NotSupportedException(); + public override long Position { get => 0; set => throw new NotSupportedException(); } + + public override void Flush() => _output.Flush(); + + public override int Read(byte[] buffer, int offset, int count) => + _input.Read(buffer, offset, count); + + public override long Seek(long offset, SeekOrigin origin) => + throw new NotSupportedException(); + + public override void SetLength(long value) => + throw new NotSupportedException(); + + public override void Write(byte[] buffer, int offset, int count) => + _output.Write(buffer, offset, count); + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _input.Dispose(); + _output.Dispose(); + } + + base.Dispose(disposing); + } + } + } +} diff --git a/EchoTcpServer.Tests/EchoTcpServer.Tests.csproj b/EchoTcpServer.Tests/EchoTcpServer.Tests.csproj new file mode 100644 index 0000000..3565a0b --- /dev/null +++ b/EchoTcpServer.Tests/EchoTcpServer.Tests.csproj @@ -0,0 +1,33 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + diff --git a/EchoTcpServer.Tests/UdpTimedSenderTests.cs b/EchoTcpServer.Tests/UdpTimedSenderTests.cs new file mode 100644 index 0000000..e8f5612 --- /dev/null +++ b/EchoTcpServer.Tests/UdpTimedSenderTests.cs @@ -0,0 +1,64 @@ +using System; +using EchoTcpServer; +using NUnit.Framework; + +namespace EchoTcpServerTests +{ + public class UdpTimedSenderTests + { + [Test] + public void StartSending_TwiceWithoutStop_Throws() + { + using var sender = new UdpTimedSender("127.0.0.1", 60001); + sender.StartSending(60_000); + + try + { + Assert.Throws(() => sender.StartSending(60_000)); + } + finally + { + sender.StopSending(); + } + } + + [Test] + public void StopSending_AfterStart_AllowsSubsequentStart() + { + using var sender = new UdpTimedSender("127.0.0.1", 60002); + + sender.StartSending(60_000); + sender.StopSending(); + + Assert.DoesNotThrow(() => sender.StartSending(60_000)); + sender.StopSending(); + } + + [Test] + public void StartSending_WithNonPositiveInterval_ThrowsArgumentOutOfRange() + { + using var sender = new UdpTimedSender("127.0.0.1", 60003); + Assert.Throws(() => sender.StartSending(0)); + Assert.Throws(() => sender.StartSending(-1)); + } + + [Test] + public void Constructor_NullHost_Throws() + { + Assert.Throws(() => new UdpTimedSender(null!, 60004)); + } + + [Test] + public void IsRunning_ReflectsTimerLifecycle() + { + using var sender = new UdpTimedSender("127.0.0.1", 60005); + Assert.That(sender.IsRunning, Is.False); + + sender.StartSending(60_000); + Assert.That(sender.IsRunning, Is.True); + + sender.StopSending(); + Assert.That(sender.IsRunning, Is.False); + } + } +} diff --git a/EchoTcpServer/EchoCore.cs b/EchoTcpServer/EchoCore.cs new file mode 100644 index 0000000..736ceb2 --- /dev/null +++ b/EchoTcpServer/EchoCore.cs @@ -0,0 +1,31 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace EchoTcpServer +{ + public static class EchoCore + { + public const int DefaultBufferSize = 8192; + + public static async Task EchoLoopAsync(Stream stream, CancellationToken token, int bufferSize = DefaultBufferSize) + { + if (stream is null) throw new ArgumentNullException(nameof(stream)); + if (bufferSize <= 0) throw new ArgumentOutOfRangeException(nameof(bufferSize)); + + byte[] buffer = new byte[bufferSize]; + long total = 0; + int bytesRead; + + while (!token.IsCancellationRequested + && (bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, token).ConfigureAwait(false)) > 0) + { + await stream.WriteAsync(buffer, 0, bytesRead, token).ConfigureAwait(false); + total += bytesRead; + } + + return total; + } + } +} diff --git a/EchoTcpServer/EchoServer.cs b/EchoTcpServer/EchoServer.cs new file mode 100644 index 0000000..a112839 --- /dev/null +++ b/EchoTcpServer/EchoServer.cs @@ -0,0 +1,79 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace EchoTcpServer +{ + /// + /// Local TCP echo server used during integration testing of NetSdrClient. + /// Not intended for production use; introduced as a refactor of the original + /// Program.cs to expose and to keep the echo + /// loop testable through . + /// + public sealed class EchoServer : IEchoServer + { + private readonly int _port; + private TcpListener? _listener; + private CancellationTokenSource? _cancellationTokenSource; + + public EchoServer(int port) + { + _port = port; + } + + public async Task StartAsync() + { + _cancellationTokenSource = new CancellationTokenSource(); + _listener = new TcpListener(IPAddress.Any, _port); + _listener.Start(); + Console.WriteLine($"Server started on port {_port}."); + + while (!_cancellationTokenSource.Token.IsCancellationRequested) + { + try + { + TcpClient client = await _listener.AcceptTcpClientAsync().ConfigureAwait(false); + Console.WriteLine("Client connected."); + + _ = Task.Run(() => HandleClientAsync(client, _cancellationTokenSource.Token)); + } + catch (ObjectDisposedException) + { + break; + } + } + + Console.WriteLine("Server shutdown."); + } + + private static async Task HandleClientAsync(TcpClient client, CancellationToken token) + { + try + { + using NetworkStream stream = client.GetStream(); + long echoed = await EchoCore.EchoLoopAsync(stream, token).ConfigureAwait(false); + Console.WriteLine($"Echoed {echoed} bytes total to the client."); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + Console.WriteLine($"Error: {ex.Message}"); + } + finally + { + client.Close(); + Console.WriteLine("Client disconnected."); + } + } + + public void Stop() + { + _cancellationTokenSource?.Cancel(); + _listener?.Stop(); + _cancellationTokenSource?.Dispose(); + _cancellationTokenSource = null; + Console.WriteLine("Server stopped."); + } + } +} diff --git a/EchoTcpServer/IEchoServer.cs b/EchoTcpServer/IEchoServer.cs new file mode 100644 index 0000000..1757598 --- /dev/null +++ b/EchoTcpServer/IEchoServer.cs @@ -0,0 +1,11 @@ +using System.Threading.Tasks; + +namespace EchoTcpServer +{ + public interface IEchoServer + { + Task StartAsync(); + + void Stop(); + } +} diff --git a/EchoTcpServer/Program.cs b/EchoTcpServer/Program.cs index 5966c57..6095b20 100644 --- a/EchoTcpServer/Program.cs +++ b/EchoTcpServer/Program.cs @@ -1,173 +1,33 @@ using System; -using System.Net; -using System.Net.Sockets; -using System.Text; -using System.Threading; using System.Threading.Tasks; +using EchoTcpServer; -/// -/// This program was designed for test purposes only -/// Not for a review -/// -public class EchoServer +internal static class Program { - private readonly int _port; - private TcpListener _listener; - private CancellationTokenSource _cancellationTokenSource; - - - public EchoServer(int port) - { - _port = port; - _cancellationTokenSource = new CancellationTokenSource(); - } - - public async Task StartAsync() - { - _listener = new TcpListener(IPAddress.Any, _port); - _listener.Start(); - Console.WriteLine($"Server started on port {_port}."); - - while (!_cancellationTokenSource.Token.IsCancellationRequested) - { - try - { - TcpClient client = await _listener.AcceptTcpClientAsync(); - Console.WriteLine("Client connected."); - - _ = Task.Run(() => HandleClientAsync(client, _cancellationTokenSource.Token)); - } - catch (ObjectDisposedException) - { - // Listener has been closed - break; - } - } - - Console.WriteLine("Server shutdown."); - } - - private async Task HandleClientAsync(TcpClient client, CancellationToken token) - { - using (NetworkStream stream = client.GetStream()) - { - try - { - byte[] buffer = new byte[8192]; - int bytesRead; - - while (!token.IsCancellationRequested && (bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, token)) > 0) - { - // Echo back the received message - await stream.WriteAsync(buffer, 0, bytesRead, token); - Console.WriteLine($"Echoed {bytesRead} bytes to the client."); - } - } - catch (Exception ex) when (!(ex is OperationCanceledException)) - { - Console.WriteLine($"Error: {ex.Message}"); - } - finally - { - client.Close(); - Console.WriteLine("Client disconnected."); - } - } - } - - public void Stop() - { - _cancellationTokenSource.Cancel(); - _listener.Stop(); - _cancellationTokenSource.Dispose(); - Console.WriteLine("Server stopped."); - } - public static async Task Main(string[] args) { - EchoServer server = new EchoServer(5000); - - // Start the server in a separate task - _ = Task.Run(() => server.StartAsync()); - - string host = "127.0.0.1"; // Target IP - int port = 60000; // Target Port - int intervalMilliseconds = 5000; // Send every 3 seconds - - using (var sender = new UdpTimedSender(host, port)) - { - Console.WriteLine("Press any key to stop sending..."); - sender.StartSending(intervalMilliseconds); - - Console.WriteLine("Press 'q' to quit..."); - while (Console.ReadKey(intercept: true).Key != ConsoleKey.Q) - { - // Just wait until 'q' is pressed - } - - sender.StopSending(); - server.Stop(); - Console.WriteLine("Sender stopped."); - } - } -} - - -public class UdpTimedSender : IDisposable -{ - private readonly string _host; - private readonly int _port; - private readonly UdpClient _udpClient; - private Timer _timer; - - public UdpTimedSender(string host, int port) - { - _host = host; - _port = port; - _udpClient = new UdpClient(); - } - - public void StartSending(int intervalMilliseconds) - { - if (_timer != null) - throw new InvalidOperationException("Sender is already running."); + IEchoServer server = new EchoServer(5000); - _timer = new Timer(SendMessageCallback, null, 0, intervalMilliseconds); - } + _ = Task.Run(server.StartAsync); - ushort i = 0; + const string host = "127.0.0.1"; + const int port = 60000; + const int intervalMilliseconds = 5000; - private void SendMessageCallback(object state) - { - try - { - //dummy data - Random rnd = new Random(); - byte[] samples = new byte[1024]; - rnd.NextBytes(samples); - i++; + using var sender = new UdpTimedSender(host, port); - byte[] msg = (new byte[] { 0x04, 0x84 }).Concat(BitConverter.GetBytes(i)).Concat(samples).ToArray(); - var endpoint = new IPEndPoint(IPAddress.Parse(_host), _port); + Console.WriteLine("Press any key to stop sending..."); + sender.StartSending(intervalMilliseconds); - _udpClient.Send(msg, msg.Length, endpoint); - Console.WriteLine($"Message sent to {_host}:{_port} "); - } - catch (Exception ex) + Console.WriteLine("Press 'q' to quit..."); + while (Console.ReadKey(intercept: true).Key != ConsoleKey.Q) { - Console.WriteLine($"Error sending message: {ex.Message}"); } - } - public void StopSending() - { - _timer?.Dispose(); - _timer = null; - } + sender.StopSending(); + server.Stop(); + Console.WriteLine("Sender stopped."); - public void Dispose() - { - StopSending(); - _udpClient.Dispose(); + await Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/EchoTcpServer/UdpTimedSender.cs b/EchoTcpServer/UdpTimedSender.cs new file mode 100644 index 0000000..5352159 --- /dev/null +++ b/EchoTcpServer/UdpTimedSender.cs @@ -0,0 +1,82 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Threading; + +namespace EchoTcpServer +{ + public sealed class UdpTimedSender : IDisposable + { + private readonly string _host; + private readonly int _port; + private readonly UdpClient _udpClient; + private Timer? _timer; + private ushort _sequence; + + public UdpTimedSender(string host, int port) + : this(host, port, new UdpClient()) + { + } + + internal UdpTimedSender(string host, int port, UdpClient udpClient) + { + _host = host ?? throw new ArgumentNullException(nameof(host)); + _port = port; + _udpClient = udpClient ?? throw new ArgumentNullException(nameof(udpClient)); + } + + public bool IsRunning => _timer is not null; + + public void StartSending(int intervalMilliseconds) + { + if (intervalMilliseconds <= 0) + { + throw new ArgumentOutOfRangeException(nameof(intervalMilliseconds)); + } + + if (_timer is not null) + { + throw new InvalidOperationException("Sender is already running."); + } + + _timer = new Timer(SendMessageCallback, null, 0, intervalMilliseconds); + } + + private void SendMessageCallback(object? state) + { + try + { + var rnd = new Random(); + byte[] samples = new byte[1024]; + rnd.NextBytes(samples); + _sequence++; + + byte[] msg = new byte[] { 0x04, 0x84 } + .Concat(BitConverter.GetBytes(_sequence)) + .Concat(samples) + .ToArray(); + var endpoint = new IPEndPoint(IPAddress.Parse(_host), _port); + + _udpClient.Send(msg, msg.Length, endpoint); + Console.WriteLine($"Message sent to {_host}:{_port} "); + } + catch (Exception ex) + { + Console.WriteLine($"Error sending message: {ex.Message}"); + } + } + + public void StopSending() + { + _timer?.Dispose(); + _timer = null; + } + + public void Dispose() + { + StopSending(); + _udpClient.Dispose(); + } + } +} diff --git a/NetSdrClient.sln b/NetSdrClient.sln index 7292a3f..8f40343 100644 --- a/NetSdrClient.sln +++ b/NetSdrClient.sln @@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EchoServer", "EchoTcpServer EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetSdrClient.ArchTests", "NetSdrClient.ArchTests\NetSdrClient.ArchTests.csproj", "{400ACC47-CE06-42B1-8C2C-6C08296F74CA}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EchoTcpServer.Tests", "EchoTcpServer.Tests\EchoTcpServer.Tests.csproj", "{86337077-C97F-42C6-B6B6-90298A4E4161}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -33,6 +35,10 @@ Global {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 + {86337077-C97F-42C6-B6B6-90298A4E4161}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {86337077-C97F-42C6-B6B6-90298A4E4161}.Debug|Any CPU.Build.0 = Debug|Any CPU + {86337077-C97F-42C6-B6B6-90298A4E4161}.Release|Any CPU.ActiveCfg = Release|Any CPU + {86337077-C97F-42C6-B6B6-90298A4E4161}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docs/labs/lab-06.md b/docs/labs/lab-06.md new file mode 100644 index 0000000..161f44b --- /dev/null +++ b/docs/labs/lab-06.md @@ -0,0 +1,130 @@ +# Лабораторна робота 6. Безпечний рефакторинг EchoServer + +**Дисципліна**: Реінжиніринг програмного забезпечення +**Студент**: Биков Нікіта Вячеславович +**Група**: ПЗС-1 +**Гілка**: `lab-06-echoserver-refactor` +**Pull Request**: створюється з `nik-bykoff:lab-06-echoserver-refactor` у `lenagrin/ReengineeringCourse:master` + +## Мета + +Зробити проєкт `EchoTcpServer` придатним до тестування юніт-тестами, не змінюючи його зовнішню поведінку (контракт CLI, формат UDP-пакетів). Покрити нові та витягнуті частини тестами, заміряти приріст coverage. + +## Хід виконання + +### 1. Розщеплення єдиного `Program.cs` + +Початковий файл [`EchoTcpServer/Program.cs`](https://github.com/lenagrin/ReengineeringCourse/blob/master/EchoTcpServer/Program.cs) (170+ рядків, три класи + точка входу) розщеплено на: + +- [`EchoCore.cs`](../../EchoTcpServer/EchoCore.cs) — `public static class EchoCore.EchoLoopAsync(Stream, CancellationToken, int bufferSize)`. Це чистий, не залежний від сокетів алгоритм відлуння. Тут і живе тестована логіка. +- [`IEchoServer.cs`](../../EchoTcpServer/IEchoServer.cs) — мінімальний контракт `StartAsync()` / `Stop()`. +- [`EchoServer.cs`](../../EchoTcpServer/EchoServer.cs) — реалізує `IEchoServer`, маршалить життєвий цикл `TcpListener` і делегує тіло циклу у `EchoCore.EchoLoopAsync`. +- [`UdpTimedSender.cs`](../../EchoTcpServer/UdpTimedSender.cs) — окремий клас із `IsRunning`, валідацією `intervalMilliseconds`, перевіркою `null` для `host`, новим `internal` конструктором для майбутнього мокінгу `UdpClient`. +- [`Program.cs`](../../EchoTcpServer/Program.cs) — лише `Main`, тільки композиція. + +Усі типи перенесено у namespace `EchoTcpServer` (раніше були у глобальному). + +### 2. Зміни поведінки, які варто зафіксувати + +- `EchoServer` тепер `sealed`; його `Stop()` ідемпотентний (повторний виклик не кидає `ObjectDisposedException`). +- `EchoServer.HandleClientAsync` стає приватним статичним методом, що делегує `EchoCore.EchoLoopAsync`. Поведінка ідентична попередній версії (та сама `byte[8192]` буфер за замовчуванням, аналогічна обробка `OperationCanceledException`). +- `UdpTimedSender` валідує аргументи (`null host`, `interval <= 0`), додано флаг `IsRunning` для прозорого життєвого циклу. + +### 3. Новий проєкт тестів `EchoTcpServer.Tests` + +Структура: + +```text +EchoTcpServer.Tests/ + EchoTcpServer.Tests.csproj (NUnit + Moq + coverlet.msbuild) + EchoCoreTests.cs (5 тестів) + UdpTimedSenderTests.cs (5 тестів) +``` + +Тести (10 шт., усі зелені): + +| Файл | Тест | Що перевіряє | +|------|------|-------------| +| `EchoCoreTests` | `EchoLoopAsync_OnPlainPayload_WritesIdenticalBytesBack` | базовий echo через `BridgeStream` (in-memory) | +| `EchoCoreTests` | `EchoLoopAsync_StopsWhenStreamClosed` | завершення на EOS | +| `EchoCoreTests` | `EchoLoopAsync_RespectsCancellationToken` | швидке завершення на cancel | +| `EchoCoreTests` | `EchoLoopAsync_NullStream_Throws` | guard-clause для `null` | +| `EchoCoreTests` | `EchoLoopAsync_NonPositiveBufferSize_Throws` | guard-clause для `bufferSize <= 0` | +| `UdpTimedSenderTests` | `StartSending_TwiceWithoutStop_Throws` | вимога ексклюзивної роботи timer-а | +| `UdpTimedSenderTests` | `StopSending_AfterStart_AllowsSubsequentStart` | повторний старт після стопу | +| `UdpTimedSenderTests` | `StartSending_WithNonPositiveInterval_ThrowsArgumentOutOfRange` | валідація | +| `UdpTimedSenderTests` | `Constructor_NullHost_Throws` | валідація `null` | +| `UdpTimedSenderTests` | `IsRunning_ReflectsTimerLifecycle` | публічний індикатор стану | + +Хелпер `BridgeStream` усередині `EchoCoreTests.cs` дозволяє підставляти `MemoryStream` як вхід та інший `MemoryStream` як вихід, що покриває echo-логіку без жодного TCP-сокета. + +## Зміни у коді та конфігурації + +| Файл | Зміна | +|------|-------| +| [`EchoTcpServer/EchoCore.cs`](../../EchoTcpServer/EchoCore.cs) | новий — testable echo loop | +| [`EchoTcpServer/IEchoServer.cs`](../../EchoTcpServer/IEchoServer.cs) | новий — інтерфейс | +| [`EchoTcpServer/EchoServer.cs`](../../EchoTcpServer/EchoServer.cs) | новий — клас сервера, sealed, реалізує `IEchoServer` | +| [`EchoTcpServer/UdpTimedSender.cs`](../../EchoTcpServer/UdpTimedSender.cs) | новий — у власному файлі, з валідацією, `IsRunning`, namespace | +| [`EchoTcpServer/Program.cs`](../../EchoTcpServer/Program.cs) | переписано — тільки `Main`/композиція | +| [`EchoTcpServer.Tests/`](../../EchoTcpServer.Tests/) | новий проєкт NUnit + Moq + coverlet.msbuild | +| [`NetSdrClient.sln`](../../NetSdrClient.sln) | + `EchoTcpServer.Tests` | + +## Як перевірити + +```bash +dotnet build NetSdrClient.sln -c Release +dotnet test NetSdrClient.sln -c Release + +# З покриттям тільки для EchoServer +cd EchoTcpServer.Tests +dotnet test -c Release \ + /p:CollectCoverage=true \ + /p:CoverletOutput=TestResults/coverage.xml \ + /p:CoverletOutputFormat=opencover \ + /p:Exclude="[EchoServer]Program" +``` + +Очікуваний результат: + +```text +Passed! - Failed: 0, Passed: 18, ... NetSdrClientAppTests.dll +Passed! - Failed: 0, Passed: 4, ... NetSdrClient.ArchTests.dll +Passed! - Failed: 0, Passed: 10, ... EchoTcpServer.Tests.dll + ++------------+--------+--------+--------+ +| Module | Line | Branch | Method | ++------------+--------+--------+--------+ +| EchoServer | 55.12% | 65.38% | 61.53% | ++------------+--------+--------+--------+ +``` + +## Метрики до/після + +| Метрика | До | Після | +|---------|----|-------| +| Кількість файлів у `EchoTcpServer/` | 1 | 5 | +| Кількість класів у `Program.cs` | 3 | 0 (тільки `Program`) | +| Тести проєкту `EchoServer` | 0 | 10 | +| Compiler warnings (`EchoTcpServer`) | 5 | 0 | +| Line coverage `EchoServer` модуля | 0% | 55.12% | +| Branch coverage | 0% | 65.38% | +| Method coverage | 0% | 61.53% | + +Залишок незакритого coverage — це сокетні гілки `EchoServer.StartAsync`/`HandleClientAsync` (потребують live-listener, який не входить в обсяг unit-тестів) та exception-гілки `UdpTimedSender.SendMessageCallback` (опційно покриваються інтеграційним тестом). + +## Висновки + +Витягання `EchoCore` як чистого алгоритму над `Stream` — стандартний приклад «зробити код тестованим без зміни поведінки»: реальний код `EchoServer` тонко делегує туди логіку, а тести працюють із in-memory `MemoryStream`. У результаті `EchoTcpServer` зник з переліку «нульове покриття» і має чистий warnings-free бекграунд. + +## Посилання + +- Martin Fowler — *Refactoring: Improving the Design of Existing Code* (Extract Method, Replace Constructor with Factory). +- [.NET docs — `Stream` testing patterns](https://learn.microsoft.com/dotnet/standard/io/handling-io-errors) + +## Скріни + +```text +[ScreenSonar11] Sonar Coverage до — EchoServer 0% +[ScreenSonar12] Sonar Coverage після — EchoServer ~55% +```