From e3ff6f2c0960eef27fae722a5bb726dd0cb27863 Mon Sep 17 00:00:00 2001 From: msjang Date: Thu, 30 Jul 2026 23:43:18 +0900 Subject: [PATCH] Add quality CI and threading tests --- .github/workflows/ci.yml | 74 ++++++++ Directory.Packages.props | 12 ++ Dreamine.Threading.csproj | 9 +- README.md | 6 + README_KO.md | 6 + .../Dreamine.Threading.Tests.csproj | 18 ++ .../ThreadingTests.cs | 166 ++++++++++++++++++ 7 files changed, 289 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 Directory.Packages.props create mode 100644 tests/Dreamine.Threading.Tests/Dreamine.Threading.Tests.csproj create mode 100644 tests/Dreamine.Threading.Tests/ThreadingTests.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..cf80467 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,74 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +env: + SONAR_PROJECT_KEY: CodeMaru-Dreamine_Dreamine.Threading + SONAR_ORGANIZATION: codemaru-dreamine + SONAR_PROJECT_VERSION: 1.0.1 + SONAR_SCANNER_VERSION: 11.2.1 + SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + +jobs: + build-test-analyze: + runs-on: windows-2025 + timeout-minutes: 15 + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + fetch-depth: 0 + - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + with: + dotnet-version: 8.0.x + - name: Install analysis tools + if: env.SONAR_TOKEN != '' + run: | + dotnet tool install --global dotnet-sonarscanner --version $env:SONAR_SCANNER_VERSION + dotnet tool install --global dotnet-coverage + - name: Align SonarCloud project + if: env.SONAR_TOKEN != '' + shell: pwsh + run: | + $headers = @{ Authorization = "Bearer $env:SONAR_TOKEN" } + $basic = [Convert]::ToBase64String([Text.Encoding]::ASCII.GetBytes("$($env:SONAR_TOKEN):")) + try { + Invoke-RestMethod -Method Post -Uri 'https://sonarcloud.io/api/autoscan/activation' -Headers @{ Authorization = "Basic $basic" } -ContentType 'application/x-www-form-urlencoded' -Body @{ projectKey = $env:SONAR_PROJECT_KEY; enable = 'false' } | Out-Null + Invoke-RestMethod -Method Post -Uri 'https://sonarcloud.io/api/new_code_periods/set' -Headers $headers -ContentType 'application/x-www-form-urlencoded' -Body @{ project = $env:SONAR_PROJECT_KEY; type = 'NUMBER_OF_DAYS'; value = '30' } | Out-Null + } catch { Write-Warning $_ } + - name: Restore + run: dotnet restore tests/Dreamine.Threading.Tests/Dreamine.Threading.Tests.csproj + - name: Begin Sonar analysis + if: env.SONAR_TOKEN != '' + run: > + dotnet sonarscanner begin + /k:"$env:SONAR_PROJECT_KEY" + /o:"$env:SONAR_ORGANIZATION" + /v:"$env:SONAR_PROJECT_VERSION" + /d:sonar.token="$env:SONAR_TOKEN" + /d:sonar.cs.vscoveragexml.reportsPaths="$env:RUNNER_TEMP\TestResults\coverage.xml" + /d:sonar.cs.vstest.reportsPaths="$env:RUNNER_TEMP\TestResults\**\*.trx" + /d:sonar.exclusions="**/bin/**,**/obj/**,**/TestResults/**" + /d:sonar.coverage.exclusions="**/tests/**" + /d:sonar.cpd.exclusions="**/tests/**" + - name: Build and test + shell: pwsh + run: | + $project = 'tests/Dreamine.Threading.Tests/Dreamine.Threading.Tests.csproj' + if ($env:SONAR_TOKEN -ne '') { + dotnet-coverage collect "dotnet test $project --no-restore --configuration Release --results-directory $env:RUNNER_TEMP\TestResults --logger `"trx;LogFileName=tests.trx`"" -f xml -o "$env:RUNNER_TEMP\TestResults\coverage.xml" + } else { + dotnet test $project --no-restore --configuration Release + } + - name: Pack + run: dotnet pack Dreamine.Threading.csproj --no-restore --configuration Release --output "${{ runner.temp }}\packages" + - name: End Sonar analysis + if: success() && env.SONAR_TOKEN != '' + run: dotnet sonarscanner end /d:sonar.token="$env:SONAR_TOKEN" diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..92ff302 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,12 @@ + + + true + + + + + + + + + diff --git a/Dreamine.Threading.csproj b/Dreamine.Threading.csproj index 3859327..81ab959 100644 --- a/Dreamine.Threading.csproj +++ b/Dreamine.Threading.csproj @@ -22,7 +22,12 @@ - + + + + + + @@ -31,4 +36,4 @@ - \ No newline at end of file + diff --git a/README.md b/README.md index 404206e..318b0a3 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,11 @@ # Dreamine.Threading +[![CI](https://github.com/CodeMaru-Dreamine/Dreamine.Threading/actions/workflows/ci.yml/badge.svg)](https://github.com/CodeMaru-Dreamine/Dreamine.Threading/actions/workflows/ci.yml) [![Quality Gate](https://sonarcloud.io/api/project_badges/measure?project=CodeMaru-Dreamine_Dreamine.Threading&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=CodeMaru-Dreamine_Dreamine.Threading) [![Security](https://sonarcloud.io/api/project_badges/measure?project=CodeMaru-Dreamine_Dreamine.Threading&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=CodeMaru-Dreamine_Dreamine.Threading) [![Coverage](https://sonarcloud.io/api/project_badges/measure?project=CodeMaru-Dreamine_Dreamine.Threading&metric=coverage)](https://sonarcloud.io/summary/new_code?id=CodeMaru-Dreamine_Dreamine.Threading) + +[![License](https://img.shields.io/github/license/CodeMaru-Dreamine/Dreamine.Threading)](LICENSE) ![.NET](https://img.shields.io/badge/.NET-8-512BD4) [![NuGet](https://img.shields.io/nuget/v/Dreamine.Threading)](https://www.nuget.org/packages/Dreamine.Threading) [![Downloads](https://img.shields.io/nuget/dt/Dreamine.Threading)](https://www.nuget.org/packages/Dreamine.Threading) + +[![Docs](https://img.shields.io/badge/Docs-dreamine.kr-2496ED)](https://dreamine.kr) [![Guide](https://img.shields.io/badge/Guide-dreamine.kr-2496ED)](https://dreamine.kr) [![Playground](https://img.shields.io/badge/Playground-dreamine.kr-6F42C1)](https://dreamine.kr) [![Book](https://img.shields.io/badge/Book-Practical_MVVM_Architecture-111111)](https://dreamine.kr) + **Dreamine.Threading** provides the core threading abstraction layer for Dreamine applications. It defines contracts, models, scheduling policies, worker-thread concepts, polling-job structure, CPU-aware cycle policies, and thread manager infrastructure used by Dreamine-based applications. diff --git a/README_KO.md b/README_KO.md index ac05b0c..23d8a39 100644 --- a/README_KO.md +++ b/README_KO.md @@ -1,5 +1,11 @@ # Dreamine.Threading +[![CI](https://github.com/CodeMaru-Dreamine/Dreamine.Threading/actions/workflows/ci.yml/badge.svg)](https://github.com/CodeMaru-Dreamine/Dreamine.Threading/actions/workflows/ci.yml) [![품질 게이트](https://sonarcloud.io/api/project_badges/measure?project=CodeMaru-Dreamine_Dreamine.Threading&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=CodeMaru-Dreamine_Dreamine.Threading) [![보안](https://sonarcloud.io/api/project_badges/measure?project=CodeMaru-Dreamine_Dreamine.Threading&metric=security_rating)](https://sonarcloud.io/summary/new_code?id=CodeMaru-Dreamine_Dreamine.Threading) [![커버리지](https://sonarcloud.io/api/project_badges/measure?project=CodeMaru-Dreamine_Dreamine.Threading&metric=coverage)](https://sonarcloud.io/summary/new_code?id=CodeMaru-Dreamine_Dreamine.Threading) + +[![라이선스](https://img.shields.io/github/license/CodeMaru-Dreamine/Dreamine.Threading)](LICENSE) ![.NET](https://img.shields.io/badge/.NET-8-512BD4) [![NuGet](https://img.shields.io/nuget/v/Dreamine.Threading)](https://www.nuget.org/packages/Dreamine.Threading) [![다운로드](https://img.shields.io/nuget/dt/Dreamine.Threading)](https://www.nuget.org/packages/Dreamine.Threading) + +[![문서](https://img.shields.io/badge/문서-dreamine.kr-2496ED)](https://dreamine.kr) [![가이드](https://img.shields.io/badge/가이드-dreamine.kr-2496ED)](https://dreamine.kr) [![실습장](https://img.shields.io/badge/실습장-dreamine.kr-6F42C1)](https://dreamine.kr) [![도서](https://img.shields.io/badge/도서-실전_MVVM_아키텍처-111111)](https://dreamine.kr) + **Dreamine.Threading**은 Dreamine 애플리케이션을 위한 핵심 스레딩 추상화 계층입니다. 이 패키지는 Dreamine 기반 애플리케이션에서 사용할 계약, 모델, 스케줄링 정책, Worker Thread 개념, Polling Job 구조, CPU 기반 Cycle Policy, Thread Manager 구조를 정의합니다. diff --git a/tests/Dreamine.Threading.Tests/Dreamine.Threading.Tests.csproj b/tests/Dreamine.Threading.Tests/Dreamine.Threading.Tests.csproj new file mode 100644 index 0000000..084588a --- /dev/null +++ b/tests/Dreamine.Threading.Tests/Dreamine.Threading.Tests.csproj @@ -0,0 +1,18 @@ + + + net8.0 + enable + enable + false + true + + + + + + + + + + + diff --git a/tests/Dreamine.Threading.Tests/ThreadingTests.cs b/tests/Dreamine.Threading.Tests/ThreadingTests.cs new file mode 100644 index 0000000..5c1b153 --- /dev/null +++ b/tests/Dreamine.Threading.Tests/ThreadingTests.cs @@ -0,0 +1,166 @@ +using Dreamine.Threading.Allocators; +using Dreamine.Threading.Interfaces; +using Dreamine.Threading.Models; +using Dreamine.Threading.Policies; +using Dreamine.Threading.Services; +using Xunit; + +namespace Dreamine.Threading.Tests; + +public sealed class ThreadingTests +{ + [Fact] + public void ThreadOptions_NormalizeInvalidValues() + { + var source = new DreamineThreadOptions + { + Name = " ", + IntervalMs = -1, + AutoThreadsPerCore = 0, + OverflowPollingIntervalMs = -1, + StopTimeout = TimeSpan.Zero, + AutoStart = false + }; + + var result = source.Normalize(); + + Assert.Equal("DreamineThread", result.Name); + Assert.Equal(10, result.IntervalMs); + Assert.Equal(2, result.AutoThreadsPerCore); + Assert.Equal(100, result.OverflowPollingIntervalMs); + Assert.Equal(TimeSpan.FromSeconds(2), result.StopTimeout); + Assert.False(result.AutoStart); + } + + [Fact] + public void JobOptions_NormalizeAndPreserveFlags() + { + var result = new DreamineThreadJobOptions + { + Name = "", + IntervalMs = -4, + IsEnabled = false, + IsOverflowPolling = true + }.Normalize(); + + Assert.Equal("DreamineThreadJob", result.Name); + Assert.Equal(10, result.IntervalMs); + Assert.False(result.IsEnabled); + Assert.True(result.IsOverflowPolling); + } + + [Fact] + public async Task Job_TracksSuccessFailureAndSchedule() + { + var calls = 0; + var job = new DreamineThreadJob( + new DreamineThreadJobOptions { Name = "poll", IntervalMs = 100 }, + _ => { calls++; return ValueTask.CompletedTask; }); + + Assert.True(job.ShouldRun(DateTimeOffset.UtcNow)); + await job.ExecuteAsync(default); + Assert.Equal(1, calls); + Assert.Equal(1, job.ExecuteCount); + Assert.NotNull(job.LastExecutedAt); + Assert.False(job.ShouldRun(job.LastExecutedAt!.Value.AddMilliseconds(99))); + Assert.True(job.ShouldRun(job.LastExecutedAt.Value.AddMilliseconds(100))); + + var failure = new InvalidOperationException("boom"); + var failing = new DreamineThreadJob(new(), _ => ValueTask.FromException(failure)); + Assert.Same(failure, await Assert.ThrowsAsync( + () => failing.ExecuteAsync(default).AsTask())); + Assert.Same(failure, failing.LastException); + Assert.Equal(0, failing.ExecuteCount); + } + + [Fact] + public void Job_DisabledNeverRunsAndZeroIntervalAlwaysRuns() + { + var disabled = new DreamineThreadJob( + new DreamineThreadJobOptions { IsEnabled = false }, + _ => ValueTask.CompletedTask); + var immediate = new DreamineThreadJob( + new DreamineThreadJobOptions { IntervalMs = 0 }, + _ => ValueTask.CompletedTask); + + Assert.False(disabled.ShouldRun(DateTimeOffset.UtcNow)); + Assert.True(immediate.ShouldRun(DateTimeOffset.UtcNow)); + } + + [Theory] + [InlineData(20, 0)] + [InlineData(30, 1)] + [InlineData(50, 3)] + [InlineData(70, 5)] + public void AdaptivePolicy_UsesCpuThresholds(double cpu, int expected) + { + var policy = new AdaptiveCpuCyclePolicy(new CpuUsage(cpu)); + var delay = policy.GetDelayMs( + new DreamineThreadOptions { IntervalMs = 0 }, + DreamineThreadCoreAssignment.None(), + Context()); + + Assert.Equal(expected, delay); + } + + [Fact] + public void CyclePolicies_HandleFixedAndOverflowIntervals() + { + var options = new DreamineThreadOptions { IntervalMs = 25, OverflowPollingIntervalMs = 77 }; + var normal = DreamineThreadCoreAssignment.None(); + var overflow = DreamineThreadCoreAssignment.Overflow(); + var context = Context(); + + Assert.Equal(25, new FixedIntervalCyclePolicy().GetDelayMs(options, normal, context)); + Assert.Equal(77, new FixedIntervalCyclePolicy().GetDelayMs(options, overflow, context)); + Assert.Equal(77, new OverflowPollingPolicy().GetDelayMs(options, normal, context)); + } + + [Fact] + public void Allocator_AssignsLeastUsedCoreAndOverflowsAtCapacity() + { + var allocator = new AutoCoreAllocator(2); + var options = new DreamineThreadOptions + { + CoreMode = DreamineThreadCoreMode.Auto, + AutoThreadsPerCore = 1 + }; + + var first = allocator.Allocate(options); + var second = allocator.Allocate(options); + var overflow = allocator.Allocate(options); + + Assert.Equal(0, first.CoreIndex); + Assert.Equal(1, second.CoreIndex); + Assert.True(overflow.IsOverflowPolling); + + allocator.Release(first); + Assert.Equal(0, allocator.Allocate(options).CoreIndex); + } + + [Fact] + public void Allocator_HandlesNoneManualAndInvalidManualCore() + { + var allocator = new AutoCoreAllocator(4); + + Assert.Null(allocator.Allocate(new() { CoreMode = DreamineThreadCoreMode.None }).CoreIndex); + Assert.Equal(2, allocator.Allocate(new() + { + CoreMode = DreamineThreadCoreMode.Manual, + CoreIndex = 2 + }).CoreIndex); + Assert.Throws(() => allocator.Allocate(new() + { + CoreMode = DreamineThreadCoreMode.Manual, + CoreIndex = 9 + })); + } + + private sealed class CpuUsage(double value) : ICpuUsageProvider + { + public double GetTotalCpuUsagePercent() => value; + } + + private static DreamineThreadCycleContext Context() => + new("worker", 1, 1, null, false, DateTimeOffset.UtcNow); +}