Skip to content

Commit b6fc3e2

Browse files
committed
test: raise core shared-assembly and host sdk coverage to 95%+
1 parent 975d137 commit b6fc3e2

10 files changed

Lines changed: 646 additions & 0 deletions

build/CoverageSummary.ps1

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
param(
2+
[Parameter(Mandatory = $true)]
3+
[string]$CoverageDir,
4+
5+
[string[]]$KeyComponentPathPrefixes = @(
6+
"Modulus.Core\",
7+
"Modulus.Sdk\",
8+
"Modulus.Cli\",
9+
"Modulus.HostSdk.",
10+
"Hosts\"
11+
),
12+
13+
[ValidateRange(0, 100)]
14+
[double]$MinLineCoveragePercent = 95.0,
15+
16+
[ValidateRange(0, 200)]
17+
[int]$ShowWorstFiles = 0
18+
)
19+
20+
$ErrorActionPreference = "Stop"
21+
22+
function Normalize-Path([string]$p) {
23+
return $p.Replace("/", "\\")
24+
}
25+
26+
function Get-ComponentName([string]$filePath, [string[]]$prefixes) {
27+
$p = Normalize-Path $filePath
28+
foreach ($prefix in $prefixes) {
29+
$pref = Normalize-Path $prefix
30+
$idx = $p.IndexOf($pref, [System.StringComparison]::OrdinalIgnoreCase)
31+
if ($idx -ge 0) {
32+
$rest = $p.Substring($idx + $pref.Length)
33+
# rest begins with e.g. "Architecture\\X.cs", map by prefix
34+
return $pref.TrimEnd("\\")
35+
}
36+
}
37+
return $null
38+
}
39+
40+
if (-not (Test-Path $CoverageDir)) {
41+
throw "CoverageDir not found: $CoverageDir"
42+
}
43+
44+
$coverageFiles = Get-ChildItem -Path $CoverageDir -Recurse -Filter "coverage.cobertura.xml" | Select-Object -ExpandProperty FullName
45+
if ($coverageFiles.Count -eq 0) {
46+
throw "No coverage.cobertura.xml files found under: $CoverageDir"
47+
}
48+
49+
Write-Host "Cobertura files: $($coverageFiles.Count)"
50+
51+
# Merge coverage by (file,line) using max(hits) across all runs.
52+
$lineHits = @{} # key: "<file>|<lineNumber>" -> hits
53+
54+
foreach ($file in $coverageFiles) {
55+
[xml]$doc = Get-Content -Path $file
56+
$classes = $doc.SelectNodes("//class[@filename]")
57+
foreach ($c in $classes) {
58+
$fn = Normalize-Path $c.filename
59+
# Ignore generated/obj/bin if present
60+
if ($fn -match "\\\\obj\\\\" -or $fn -match "\\\\bin\\\\") { continue }
61+
62+
$lines = $c.SelectNodes("./lines/line[@number and @hits]")
63+
foreach ($l in $lines) {
64+
$n = [int]$l.number
65+
$hits = [int]$l.hits
66+
$k = "$fn|$n"
67+
if ($lineHits.ContainsKey($k)) {
68+
if ($hits -gt $lineHits[$k]) { $lineHits[$k] = $hits }
69+
} else {
70+
$lineHits[$k] = $hits
71+
}
72+
}
73+
}
74+
}
75+
76+
if ($lineHits.Count -eq 0) {
77+
throw "No line coverage entries found in Cobertura files."
78+
}
79+
80+
# Aggregate per component (by path prefix)
81+
$componentTotal = @{} # component -> total lines
82+
$componentCovered = @{} # component -> covered lines
83+
$fileTotal = @{} # filename -> total lines
84+
$fileCovered = @{} # filename -> covered lines
85+
86+
foreach ($k in $lineHits.Keys) {
87+
$parts = $k.Split("|", 2)
88+
$fn = $parts[0]
89+
$hits = [int]$lineHits[$k]
90+
91+
$comp = Get-ComponentName -filePath $fn -prefixes $KeyComponentPathPrefixes
92+
if ($null -eq $comp) { continue }
93+
94+
if (-not $componentTotal.ContainsKey($comp)) {
95+
$componentTotal[$comp] = 0
96+
$componentCovered[$comp] = 0
97+
}
98+
99+
$componentTotal[$comp] = [int]$componentTotal[$comp] + 1
100+
if ($hits -gt 0) {
101+
$componentCovered[$comp] = [int]$componentCovered[$comp] + 1
102+
}
103+
104+
if (-not $fileTotal.ContainsKey($fn)) {
105+
$fileTotal[$fn] = 0
106+
$fileCovered[$fn] = 0
107+
}
108+
$fileTotal[$fn] = [int]$fileTotal[$fn] + 1
109+
if ($hits -gt 0) {
110+
$fileCovered[$fn] = [int]$fileCovered[$fn] + 1
111+
}
112+
}
113+
114+
if ($componentTotal.Keys.Count -eq 0) {
115+
throw "No key-component source files matched. Prefixes: $($KeyComponentPathPrefixes -join ', ')"
116+
}
117+
118+
$failed = $false
119+
120+
Write-Host ""
121+
Write-Host "Key component line coverage (threshold: $MinLineCoveragePercent%)"
122+
123+
foreach ($comp in ($componentTotal.Keys | Sort-Object)) {
124+
$total = [int]$componentTotal[$comp]
125+
$covered = [int]$componentCovered[$comp]
126+
$rate = if ($total -eq 0) { 0.0 } else { ($covered * 100.0) / $total }
127+
$status = if ($rate -ge $MinLineCoveragePercent) { "OK" } else { "FAIL" }
128+
if ($status -eq "FAIL") { $failed = $true }
129+
130+
"{0,-25} {1,6:F2}% ({2}/{3}) {4}" -f $comp, $rate, $covered, $total, $status | Write-Host
131+
}
132+
133+
if ($ShowWorstFiles -gt 0) {
134+
Write-Host ""
135+
Write-Host "Worst covered files (top $ShowWorstFiles):"
136+
137+
$rows = foreach ($fn in $fileTotal.Keys) {
138+
$total = [int]$fileTotal[$fn]
139+
$covered = [int]$fileCovered[$fn]
140+
$rate = if ($total -eq 0) { 0.0 } else { ($covered * 100.0) / $total }
141+
[pscustomobject]@{
142+
File = $fn
143+
LineCoveragePercent = [math]::Round($rate, 2)
144+
Covered = $covered
145+
Total = $total
146+
}
147+
}
148+
149+
$rows | Sort-Object LineCoveragePercent, File | Select-Object -First $ShowWorstFiles | Format-Table -AutoSize | Out-String | Write-Host
150+
}
151+
152+
if ($failed) {
153+
throw "Coverage gate failed: one or more key components are below $MinLineCoveragePercent% line coverage."
154+
}
155+
156+

tests/Modulus.Cli.IntegrationTests/Commands/NewCommandTests.cs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Modulus.Cli.IntegrationTests.Infrastructure;
2+
using System.Diagnostics;
23

34
namespace Modulus.Cli.IntegrationTests.Commands;
45

@@ -55,6 +56,46 @@ public async Task New_Blazor_CreatesModuleSuccessfully()
5556
Assert.True(File.Exists(Path.Combine(moduleDir, "BlazorModule.sln")), "Solution file not created");
5657
Assert.True(Directory.Exists(Path.Combine(moduleDir, "BlazorModule.UI.Blazor")), "UI project not created");
5758
}
59+
60+
[Fact]
61+
public async Task New_AvaloniaApp_CreatesHostAppAndBuildsSuccessfully()
62+
{
63+
// Act
64+
var result = await _runner.NewAsync("TestHostApp", template: "avaloniaapp");
65+
66+
// Assert (creation)
67+
Assert.True(result.IsSuccess, $"Command failed: {result.CombinedOutput}");
68+
Assert.Contains("Created TestHostApp.sln", result.StandardOutput);
69+
Assert.Contains("Created TestHostApp.Host.Avalonia/", result.StandardOutput);
70+
71+
var appDir = Path.Combine(_context.WorkingDirectory, "TestHostApp");
72+
var slnPath = Path.Combine(appDir, "TestHostApp.sln");
73+
Assert.True(File.Exists(slnPath), "Solution file not created");
74+
75+
// Assert (build)
76+
var build = await RunDotNetAsync($"build \"{slnPath}\" -c Release", appDir);
77+
Assert.True(build.ExitCode == 0, $"dotnet build failed: {build.Output}");
78+
}
79+
80+
[Fact]
81+
public async Task New_BlazorApp_CreatesHostAppAndBuildsSuccessfully()
82+
{
83+
// Act
84+
var result = await _runner.NewAsync("TestBlazorHost", template: "blazorapp");
85+
86+
// Assert (creation)
87+
Assert.True(result.IsSuccess, $"Command failed: {result.CombinedOutput}");
88+
Assert.Contains("Created TestBlazorHost.sln", result.StandardOutput);
89+
Assert.Contains("Created TestBlazorHost.Host.Blazor/", result.StandardOutput);
90+
91+
var appDir = Path.Combine(_context.WorkingDirectory, "TestBlazorHost");
92+
var slnPath = Path.Combine(appDir, "TestBlazorHost.sln");
93+
Assert.True(File.Exists(slnPath), "Solution file not created");
94+
95+
// Assert (build)
96+
var build = await RunDotNetAsync($"build \"{slnPath}\" -c Release", appDir);
97+
Assert.True(build.ExitCode == 0, $"dotnet build failed: {build.Output}");
98+
}
5899

59100
[Fact]
60101
public async Task New_WithOutputOption_CreatesInSpecifiedDirectory()
@@ -118,6 +159,30 @@ public void Dispose()
118159
{
119160
_context.Dispose();
120161
}
162+
163+
private static async Task<(int ExitCode, string Output)> RunDotNetAsync(string arguments, string workingDirectory)
164+
{
165+
var psi = new ProcessStartInfo
166+
{
167+
FileName = "dotnet",
168+
Arguments = arguments,
169+
WorkingDirectory = workingDirectory,
170+
RedirectStandardOutput = true,
171+
RedirectStandardError = true,
172+
UseShellExecute = false,
173+
CreateNoWindow = true
174+
};
175+
176+
using var process = new Process { StartInfo = psi };
177+
process.Start();
178+
179+
var stdoutTask = process.StandardOutput.ReadToEndAsync();
180+
var stderrTask = process.StandardError.ReadToEndAsync();
181+
await Task.WhenAll(process.WaitForExitAsync(), stdoutTask, stderrTask);
182+
183+
var output = (await stdoutTask) + Environment.NewLine + (await stderrTask);
184+
return (process.ExitCode, output);
185+
}
121186
}
122187

123188

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
using System.Reflection;
2+
using System.Reflection.Emit;
3+
using Modulus.Architecture;
4+
using Modulus.Core.Architecture;
5+
6+
namespace Modulus.Core.Tests.Architecture;
7+
8+
public sealed class SharedAssemblyCatalogConflictTests
9+
{
10+
private static Assembly CreateDynamicAssemblyWithDomain(string simpleName, AssemblyDomainType domainType)
11+
{
12+
var asmName = new AssemblyName(simpleName);
13+
var asmBuilder = AssemblyBuilder.DefineDynamicAssembly(asmName, AssemblyBuilderAccess.Run);
14+
15+
var ctor = typeof(AssemblyDomainAttribute).GetConstructor(new[] { typeof(AssemblyDomainType) });
16+
var attr = new CustomAttributeBuilder(ctor!, new object[] { domainType });
17+
asmBuilder.SetCustomAttribute(attr);
18+
19+
return asmBuilder;
20+
}
21+
22+
[Fact]
23+
public void FromAssemblies_WhenHostConfigAddsModuleDomainAssembly_AddsMismatchEntry()
24+
{
25+
var moduleAsm = CreateDynamicAssemblyWithDomain("Conflicting.Assembly", AssemblyDomainType.Module);
26+
27+
var catalog = SharedAssemblyCatalog.FromAssemblies(
28+
new[] { moduleAsm },
29+
configuredAssemblies: new[] { "Conflicting.Assembly" });
30+
31+
Assert.Contains("Conflicting.Assembly", catalog.Names);
32+
33+
var entry = catalog.GetEntries().First(e => e.Name == "Conflicting.Assembly");
34+
Assert.True(entry.HasMismatch);
35+
Assert.Equal(SharedAssemblySource.HostConfig, entry.Source);
36+
37+
Assert.Contains(catalog.GetMismatches(), m => m.AssemblyName == "Conflicting.Assembly");
38+
}
39+
40+
[Fact]
41+
public void IsShared_WhenEntryExistsButDeclaredModuleDomain_StillReturnsTrue()
42+
{
43+
var moduleAsm = CreateDynamicAssemblyWithDomain("SharedButModule", AssemblyDomainType.Module);
44+
45+
var catalog = SharedAssemblyCatalog.FromAssemblies(
46+
new[] { moduleAsm },
47+
configuredAssemblies: new[] { "SharedButModule" });
48+
49+
Assert.True(catalog.IsShared(new AssemblyName("SharedButModule")));
50+
}
51+
52+
[Fact]
53+
public void AddManifestHints_WhenHintIsModuleDomain_AddsMismatchAndStillAddsEntry()
54+
{
55+
var moduleAsm = CreateDynamicAssemblyWithDomain("Hinted.ModuleAsm", AssemblyDomainType.Module);
56+
var catalog = SharedAssemblyCatalog.FromAssemblies(new[] { moduleAsm });
57+
58+
var mismatches = catalog.AddManifestHints("module1", new[] { "Hinted.ModuleAsm" });
59+
60+
Assert.Single(mismatches);
61+
Assert.Contains(mismatches, m => m.AssemblyName == "Hinted.ModuleAsm");
62+
Assert.Contains("Hinted.ModuleAsm", catalog.Names);
63+
64+
var entry = catalog.GetEntries().First(e => e.Name == "Hinted.ModuleAsm");
65+
Assert.True(entry.HasMismatch);
66+
Assert.Equal(SharedAssemblySource.ManifestHint, entry.Source);
67+
}
68+
}
69+
70+

tests/Modulus.Core.Tests/Architecture/SharedAssemblyCatalogTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ public void GetDiagnostics_ReturnsCorrectCounts()
208208
Assert.NotEmpty(diagnostics.DomainEntries);
209209
Assert.Equal(2, diagnostics.ConfigEntries.Count);
210210
Assert.Single(diagnostics.ManifestEntries);
211+
Assert.NotNull(diagnostics.PrefixRules);
211212
}
212213

213214
[Fact]
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
using Modulus.Core.Architecture;
2+
3+
namespace Modulus.Core.Tests.Architecture;
4+
5+
public sealed class SharedAssemblyOptionsTests
6+
{
7+
[Fact]
8+
public void Constants_AreNonEmpty()
9+
{
10+
Assert.False(string.IsNullOrWhiteSpace(SharedAssemblyOptions.SectionPath));
11+
Assert.False(string.IsNullOrWhiteSpace(SharedAssemblyOptions.PrefixesSectionPath));
12+
Assert.True(SharedAssemblyOptions.MaxAssemblyNameLength > 0);
13+
Assert.True(SharedAssemblyOptions.MaxManifestHints > 0);
14+
}
15+
16+
[Fact]
17+
public void Defaults_AreInitialized()
18+
{
19+
var options = new SharedAssemblyOptions();
20+
Assert.NotNull(options.Assemblies);
21+
Assert.NotNull(options.Prefixes);
22+
Assert.Empty(options.Assemblies);
23+
Assert.Empty(options.Prefixes);
24+
}
25+
}
26+
27+

0 commit comments

Comments
 (0)