finish off sprint advisories and sprints
This commit is contained in:
@@ -0,0 +1,305 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-001 - Extract EWS Weights to Manifest Files
|
||||
|
||||
using System.Text.Json;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.Signals.Tests.EvidenceWeightedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for WeightManifest and related types.
|
||||
/// </summary>
|
||||
public class WeightManifestTests
|
||||
{
|
||||
#region WeightManifest Conversion Tests
|
||||
|
||||
[Fact]
|
||||
public void ToEvidenceWeights_WithDefaultManifest_ReturnsCorrectWeights()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = CreateDefaultManifest();
|
||||
|
||||
// Act
|
||||
var weights = manifest.ToEvidenceWeights();
|
||||
|
||||
// Assert - Legacy weights
|
||||
Assert.Equal(0.30, weights.Rch);
|
||||
Assert.Equal(0.25, weights.Rts);
|
||||
Assert.Equal(0.15, weights.Bkp);
|
||||
Assert.Equal(0.15, weights.Xpl);
|
||||
Assert.Equal(0.10, weights.Src);
|
||||
Assert.Equal(0.10, weights.Mit);
|
||||
|
||||
// Assert - Advisory weights
|
||||
Assert.Equal(0.25, weights.Cvss);
|
||||
Assert.Equal(0.30, weights.Epss);
|
||||
Assert.Equal(0.20, weights.Reachability);
|
||||
Assert.Equal(0.10, weights.ExploitMaturity);
|
||||
Assert.Equal(0.15, weights.PatchProof);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromEvidenceWeights_RoundTrip_PreservesValues()
|
||||
{
|
||||
// Arrange
|
||||
var original = EvidenceWeights.Default;
|
||||
|
||||
// Act
|
||||
var manifest = WeightManifest.FromEvidenceWeights(original, "v-test");
|
||||
var restored = manifest.ToEvidenceWeights();
|
||||
|
||||
// Assert - Legacy weights match
|
||||
Assert.Equal(original.Rch, restored.Rch);
|
||||
Assert.Equal(original.Rts, restored.Rts);
|
||||
Assert.Equal(original.Bkp, restored.Bkp);
|
||||
Assert.Equal(original.Xpl, restored.Xpl);
|
||||
Assert.Equal(original.Src, restored.Src);
|
||||
Assert.Equal(original.Mit, restored.Mit);
|
||||
|
||||
// Assert - Advisory weights match
|
||||
Assert.Equal(original.Cvss, restored.Cvss);
|
||||
Assert.Equal(original.Epss, restored.Epss);
|
||||
Assert.Equal(original.Reachability, restored.Reachability);
|
||||
Assert.Equal(original.ExploitMaturity, restored.ExploitMaturity);
|
||||
Assert.Equal(original.PatchProof, restored.PatchProof);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToEvidenceWeights_WithMissingLegacy_UsesDefaults()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = new WeightManifest
|
||||
{
|
||||
Version = "v-test",
|
||||
Weights = new WeightDefinitions
|
||||
{
|
||||
Advisory = new AdvisoryWeights
|
||||
{
|
||||
Cvss = 0.25,
|
||||
Epss = 0.30,
|
||||
Reachability = 0.20,
|
||||
ExploitMaturity = 0.10,
|
||||
PatchProof = 0.15
|
||||
}
|
||||
// Legacy is null
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var weights = manifest.ToEvidenceWeights();
|
||||
|
||||
// Assert - Legacy weights should use defaults
|
||||
Assert.Equal(0.30, weights.Rch);
|
||||
Assert.Equal(0.25, weights.Rts);
|
||||
Assert.Equal(0.15, weights.Bkp);
|
||||
Assert.Equal(0.15, weights.Xpl);
|
||||
Assert.Equal(0.10, weights.Src);
|
||||
Assert.Equal(0.10, weights.Mit);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Content Hash Tests
|
||||
|
||||
[Fact]
|
||||
public void ComputeContentHash_ProducesDeterministicHash()
|
||||
{
|
||||
// Arrange
|
||||
var json = """{"version": "v-test", "weights": {}}""";
|
||||
|
||||
// Act
|
||||
var hash1 = WeightManifest.ComputeContentHash(json);
|
||||
var hash2 = WeightManifest.ComputeContentHash(json);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(hash1, hash2);
|
||||
Assert.StartsWith("sha256:", hash1);
|
||||
Assert.Equal(71, hash1.Length); // "sha256:" + 64 hex chars
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ComputeContentHash_DifferentContent_ProducesDifferentHash()
|
||||
{
|
||||
// Arrange
|
||||
var json1 = """{"version": "v1", "weights": {}}""";
|
||||
var json2 = """{"version": "v2", "weights": {}}""";
|
||||
|
||||
// Act
|
||||
var hash1 = WeightManifest.ComputeContentHash(json1);
|
||||
var hash2 = WeightManifest.ComputeContentHash(json2);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(hash1, hash2);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Serialization Tests
|
||||
|
||||
[Fact]
|
||||
public void WeightManifest_SerializesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var manifest = CreateDefaultManifest();
|
||||
|
||||
// Act
|
||||
var json = JsonSerializer.Serialize(manifest, new JsonSerializerOptions { WriteIndented = true });
|
||||
var deserialized = JsonSerializer.Deserialize<WeightManifest>(json);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(deserialized);
|
||||
Assert.Equal(manifest.Version, deserialized.Version);
|
||||
Assert.Equal(manifest.Profile, deserialized.Profile);
|
||||
Assert.Equal(manifest.Weights.Legacy?.Rch, deserialized.Weights.Legacy?.Rch);
|
||||
Assert.Equal(manifest.Weights.Advisory?.Cvss, deserialized.Weights.Advisory?.Cvss);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeightManifest_DeserializesFromFile_WhenValid()
|
||||
{
|
||||
// Arrange - Sample JSON matching etc/weights/v2026-01-22.weights.json structure
|
||||
var json = """
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"version": "v2026-01-22",
|
||||
"effectiveFrom": "2026-01-22T00:00:00Z",
|
||||
"profile": "production",
|
||||
"contentHash": "sha256:auto",
|
||||
"weights": {
|
||||
"legacy": {
|
||||
"rch": 0.30,
|
||||
"rts": 0.25,
|
||||
"bkp": 0.15,
|
||||
"xpl": 0.15,
|
||||
"src": 0.10,
|
||||
"mit": 0.10
|
||||
},
|
||||
"advisory": {
|
||||
"cvss": 0.25,
|
||||
"epss": 0.30,
|
||||
"reachability": 0.20,
|
||||
"exploitMaturity": 0.10,
|
||||
"patchProof": 0.15
|
||||
}
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
var manifest = JsonSerializer.Deserialize<WeightManifest>(json, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(manifest);
|
||||
Assert.Equal("v2026-01-22", manifest.Version);
|
||||
Assert.Equal("production", manifest.Profile);
|
||||
Assert.Equal(0.30, manifest.Weights.Legacy?.Rch);
|
||||
Assert.Equal(0.25, manifest.Weights.Advisory?.Cvss);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Guardrail Tests
|
||||
|
||||
[Fact]
|
||||
public void GuardrailDefinitions_DeserializeCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var json = """
|
||||
{
|
||||
"notAffectedCap": {
|
||||
"enabled": true,
|
||||
"maxScore": 15,
|
||||
"requiresBkpMin": 1.0,
|
||||
"requiresRtsMax": 0.6
|
||||
},
|
||||
"runtimeFloor": {
|
||||
"enabled": true,
|
||||
"minScore": 60,
|
||||
"requiresRtsMin": 0.8
|
||||
}
|
||||
}
|
||||
""";
|
||||
|
||||
// Act
|
||||
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
|
||||
var guardrails = JsonSerializer.Deserialize<GuardrailDefinitions>(json, options);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(guardrails);
|
||||
Assert.True(guardrails.NotAffectedCap?.Enabled);
|
||||
Assert.Equal(15, guardrails.NotAffectedCap?.MaxScore);
|
||||
Assert.Equal(1.0, guardrails.NotAffectedCap?.RequiresBkpMin);
|
||||
Assert.True(guardrails.RuntimeFloor?.Enabled);
|
||||
Assert.Equal(60, guardrails.RuntimeFloor?.MinScore);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Scoring Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public void ToEvidenceWeights_IdenticalScoring_WithDefaultWeights()
|
||||
{
|
||||
// Arrange - Load from manifest
|
||||
var manifest = CreateDefaultManifest();
|
||||
var manifestWeights = manifest.ToEvidenceWeights();
|
||||
|
||||
// Reference - Direct EvidenceWeights.Default
|
||||
var defaultWeights = EvidenceWeights.Default;
|
||||
|
||||
// Assert - All weights must match for identical scoring
|
||||
Assert.Equal(defaultWeights.Rch, manifestWeights.Rch);
|
||||
Assert.Equal(defaultWeights.Rts, manifestWeights.Rts);
|
||||
Assert.Equal(defaultWeights.Bkp, manifestWeights.Bkp);
|
||||
Assert.Equal(defaultWeights.Xpl, manifestWeights.Xpl);
|
||||
Assert.Equal(defaultWeights.Src, manifestWeights.Src);
|
||||
Assert.Equal(defaultWeights.Mit, manifestWeights.Mit);
|
||||
Assert.Equal(defaultWeights.Cvss, manifestWeights.Cvss);
|
||||
Assert.Equal(defaultWeights.Epss, manifestWeights.Epss);
|
||||
Assert.Equal(defaultWeights.Reachability, manifestWeights.Reachability);
|
||||
Assert.Equal(defaultWeights.ExploitMaturity, manifestWeights.ExploitMaturity);
|
||||
Assert.Equal(defaultWeights.PatchProof, manifestWeights.PatchProof);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static WeightManifest CreateDefaultManifest()
|
||||
{
|
||||
return new WeightManifest
|
||||
{
|
||||
SchemaVersion = "1.0.0",
|
||||
Version = "v2026-01-22",
|
||||
EffectiveFrom = new DateTimeOffset(2026, 1, 22, 0, 0, 0, TimeSpan.Zero),
|
||||
Profile = "production",
|
||||
Description = "Test manifest",
|
||||
Weights = new WeightDefinitions
|
||||
{
|
||||
Legacy = new LegacyWeights
|
||||
{
|
||||
Rch = 0.30,
|
||||
Rts = 0.25,
|
||||
Bkp = 0.15,
|
||||
Xpl = 0.15,
|
||||
Src = 0.10,
|
||||
Mit = 0.10
|
||||
},
|
||||
Advisory = new AdvisoryWeights
|
||||
{
|
||||
Cvss = 0.25,
|
||||
Epss = 0.30,
|
||||
Reachability = 0.20,
|
||||
ExploitMaturity = 0.10,
|
||||
PatchProof = 0.15
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
{
|
||||
"$schema": "./golden-fixtures.schema.json",
|
||||
"description": "Golden test fixtures for UnifiedScore determinism verification",
|
||||
"version": "1.0.0",
|
||||
"generated_at": "2026-01-22T00:00:00Z",
|
||||
"fixtures": [
|
||||
{
|
||||
"name": "high_risk_act_now",
|
||||
"description": "High-risk scenario with full signal coverage - should be ActNow",
|
||||
"input": {
|
||||
"ews": {
|
||||
"rch": 1.0,
|
||||
"rts": 1.0,
|
||||
"bkp": 0.0,
|
||||
"xpl": 1.0,
|
||||
"src": 1.0,
|
||||
"mit": 0.0
|
||||
},
|
||||
"signals": {
|
||||
"vex": "present",
|
||||
"epss": "present",
|
||||
"reachability": "present",
|
||||
"runtime": "present",
|
||||
"backport": "present",
|
||||
"sbom": "present"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"score_range": [90, 100],
|
||||
"bucket": "ActNow",
|
||||
"unknowns_fraction": 0.0,
|
||||
"unknowns_band": "Complete"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "low_risk_watchlist",
|
||||
"description": "Low-risk scenario with full mitigation - should be Watchlist",
|
||||
"input": {
|
||||
"ews": {
|
||||
"rch": 0.0,
|
||||
"rts": 0.0,
|
||||
"bkp": 1.0,
|
||||
"xpl": 0.0,
|
||||
"src": 0.0,
|
||||
"mit": 1.0
|
||||
},
|
||||
"signals": {
|
||||
"vex": "present",
|
||||
"epss": "present",
|
||||
"reachability": "present",
|
||||
"runtime": "present",
|
||||
"backport": "present",
|
||||
"sbom": "present"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"score_range": [0, 20],
|
||||
"bucket": "Watchlist",
|
||||
"unknowns_fraction": 0.0,
|
||||
"unknowns_band": "Complete"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "sparse_signals",
|
||||
"description": "Mid-range score with sparse signal coverage",
|
||||
"input": {
|
||||
"ews": {
|
||||
"rch": 0.5,
|
||||
"rts": 0.5,
|
||||
"bkp": 0.5,
|
||||
"xpl": 0.5,
|
||||
"src": 0.5,
|
||||
"mit": 0.0
|
||||
},
|
||||
"signals": {
|
||||
"vex": "not_queried",
|
||||
"epss": "present",
|
||||
"reachability": "not_queried",
|
||||
"runtime": "not_queried",
|
||||
"backport": "present",
|
||||
"sbom": "present"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"score_range": [40, 60],
|
||||
"bucket": "ScheduleNext",
|
||||
"unknowns_fraction_range": [0.4, 0.6],
|
||||
"unknowns_band": "Sparse"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "insufficient_signals",
|
||||
"description": "All signals missing - should be Insufficient band",
|
||||
"input": {
|
||||
"ews": {
|
||||
"rch": 0.5,
|
||||
"rts": 0.5,
|
||||
"bkp": 0.5,
|
||||
"xpl": 0.5,
|
||||
"src": 0.5,
|
||||
"mit": 0.0
|
||||
},
|
||||
"signals": {
|
||||
"vex": "not_queried",
|
||||
"epss": "not_queried",
|
||||
"reachability": "not_queried",
|
||||
"runtime": "not_queried",
|
||||
"backport": "not_queried",
|
||||
"sbom": "not_queried"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"score_range": [40, 60],
|
||||
"unknowns_fraction": 1.0,
|
||||
"unknowns_band": "Insufficient"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "adequate_signals",
|
||||
"description": "5 of 6 signals present - should be Complete or Adequate band",
|
||||
"input": {
|
||||
"ews": {
|
||||
"rch": 0.7,
|
||||
"rts": 0.6,
|
||||
"bkp": 0.3,
|
||||
"xpl": 0.5,
|
||||
"src": 0.4,
|
||||
"mit": 0.1
|
||||
},
|
||||
"signals": {
|
||||
"vex": "present",
|
||||
"epss": "present",
|
||||
"reachability": "present",
|
||||
"runtime": "present",
|
||||
"backport": "not_queried",
|
||||
"sbom": "present"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"score_range": [50, 70],
|
||||
"bucket": "ScheduleNext",
|
||||
"unknowns_fraction_range": [0.0, 0.2],
|
||||
"unknowns_band": "Complete"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "vex_not_affected",
|
||||
"description": "Scenario where VEX not_affected would significantly reduce score",
|
||||
"input": {
|
||||
"ews": {
|
||||
"rch": 0.8,
|
||||
"rts": 0.7,
|
||||
"bkp": 0.0,
|
||||
"xpl": 0.6,
|
||||
"src": 0.5,
|
||||
"mit": 0.0
|
||||
},
|
||||
"signals": {
|
||||
"vex": "not_queried",
|
||||
"epss": "present",
|
||||
"reachability": "present",
|
||||
"runtime": "present",
|
||||
"backport": "present",
|
||||
"sbom": "present"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"score_range": [60, 80],
|
||||
"bucket": "ScheduleNext",
|
||||
"has_delta_for_signal": "VEX"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "schedule_next_medium_risk",
|
||||
"description": "Medium-risk scenario that should be ScheduleNext bucket",
|
||||
"input": {
|
||||
"ews": {
|
||||
"rch": 0.6,
|
||||
"rts": 0.5,
|
||||
"bkp": 0.4,
|
||||
"xpl": 0.5,
|
||||
"src": 0.4,
|
||||
"mit": 0.2
|
||||
},
|
||||
"signals": {
|
||||
"vex": "present",
|
||||
"epss": "present",
|
||||
"reachability": "present",
|
||||
"runtime": "present",
|
||||
"backport": "present",
|
||||
"sbom": "present"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"score_range": [50, 70],
|
||||
"bucket": "ScheduleNext",
|
||||
"unknowns_fraction": 0.0,
|
||||
"unknowns_band": "Complete"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "investigate_borderline",
|
||||
"description": "Borderline scenario between ScheduleNext and Investigate",
|
||||
"input": {
|
||||
"ews": {
|
||||
"rch": 0.5,
|
||||
"rts": 0.4,
|
||||
"bkp": 0.5,
|
||||
"xpl": 0.4,
|
||||
"src": 0.3,
|
||||
"mit": 0.3
|
||||
},
|
||||
"signals": {
|
||||
"vex": "present",
|
||||
"epss": "present",
|
||||
"reachability": "present",
|
||||
"runtime": "present",
|
||||
"backport": "present",
|
||||
"sbom": "present"
|
||||
}
|
||||
},
|
||||
"expected": {
|
||||
"score_range": [35, 50],
|
||||
"unknowns_fraction": 0.0,
|
||||
"unknowns_band": "Complete"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<!-- FsCheck for property-based testing (EvidenceWeightedScore) -->
|
||||
<PackageReference Include="FsCheck" />
|
||||
<PackageReference Include="FsCheck.Xunit.v3" />
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-009 - Determinism & Replay Tests
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
using StellaOps.Signals.UnifiedScore;
|
||||
|
||||
namespace StellaOps.Signals.Tests.UnifiedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Determinism tests verifying that the unified facade maintains deterministic outputs.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
[Trait("Category", "Determinism")]
|
||||
public sealed class UnifiedScoreDeterminismTests
|
||||
{
|
||||
private readonly IEvidenceWeightedScoreCalculator _ewsCalculator;
|
||||
private readonly IWeightManifestLoader _manifestLoader;
|
||||
private readonly UnifiedScoreService _service;
|
||||
private readonly WeightManifest _testManifest;
|
||||
|
||||
public UnifiedScoreDeterminismTests()
|
||||
{
|
||||
_ewsCalculator = new EvidenceWeightedScoreCalculator();
|
||||
_manifestLoader = Substitute.For<IWeightManifestLoader>();
|
||||
|
||||
// Use a fixed manifest for deterministic testing
|
||||
_testManifest = WeightManifest.FromEvidenceWeights(EvidenceWeights.Default, "v-determinism-test");
|
||||
_manifestLoader
|
||||
.LoadLatestAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(_testManifest);
|
||||
_manifestLoader
|
||||
.LoadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(_testManifest);
|
||||
|
||||
_service = new UnifiedScoreService(
|
||||
_ewsCalculator,
|
||||
_manifestLoader,
|
||||
NullLogger<UnifiedScoreService>.Instance);
|
||||
}
|
||||
|
||||
#region Iteration Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_SameInputs_ProducesSameScore_100Iterations()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateDeterministicRequest();
|
||||
var results = new List<double>();
|
||||
|
||||
// Act - Run 100 iterations
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var result = await _service.ComputeAsync(request);
|
||||
results.Add(result.Score);
|
||||
}
|
||||
|
||||
// Assert - All scores should be identical
|
||||
results.Should().AllSatisfy(score => score.Should().Be(results[0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_SameInputs_ProducesSameDigest_100Iterations()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateDeterministicRequest();
|
||||
var digests = new List<string>();
|
||||
|
||||
// Act - Run 100 iterations
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var result = await _service.ComputeAsync(request);
|
||||
digests.Add(result.EwsDigest);
|
||||
}
|
||||
|
||||
// Assert - All digests should be identical
|
||||
digests.Should().AllSatisfy(digest => digest.Should().Be(digests[0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_SameInputs_ProducesSameFingerprint_100Iterations()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateDeterministicRequest();
|
||||
var fingerprints = new List<string>();
|
||||
|
||||
// Act - Run 100 iterations
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var result = await _service.ComputeAsync(request);
|
||||
fingerprints.Add(result.DeterminizationFingerprint ?? "");
|
||||
}
|
||||
|
||||
// Assert - All fingerprints should be identical
|
||||
fingerprints.Should().AllSatisfy(fp => fp.Should().Be(fingerprints[0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_SameInputs_ProducesSameBucket_100Iterations()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateDeterministicRequest();
|
||||
var buckets = new List<ScoreBucket>();
|
||||
|
||||
// Act - Run 100 iterations
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var result = await _service.ComputeAsync(request);
|
||||
buckets.Add(result.Bucket);
|
||||
}
|
||||
|
||||
// Assert - All buckets should be identical
|
||||
buckets.Should().AllSatisfy(bucket => bucket.Should().Be(buckets[0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_SameInputs_ProducesSameBreakdown_100Iterations()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateDeterministicRequest();
|
||||
var breakdowns = new List<string>();
|
||||
|
||||
// Act - Run 100 iterations
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var result = await _service.ComputeAsync(request);
|
||||
var serialized = JsonSerializer.Serialize(result.Breakdown);
|
||||
breakdowns.Add(serialized);
|
||||
}
|
||||
|
||||
// Assert - All breakdowns should be identical
|
||||
breakdowns.Should().AllSatisfy(bd => bd.Should().Be(breakdowns[0]));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Delta Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_SameInputs_ProducesSameDeltas_100Iterations()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.5,
|
||||
Rts = 0.5,
|
||||
Bkp = 0.5,
|
||||
Xpl = 0.5,
|
||||
Src = 0.5,
|
||||
Mit = 0.1
|
||||
},
|
||||
SignalSnapshot = new SignalSnapshot
|
||||
{
|
||||
Vex = SignalState.NotQueried(),
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.NotQueried(),
|
||||
Runtime = SignalState.Present(),
|
||||
Backport = SignalState.Present(),
|
||||
Sbom = SignalState.Present(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
IncludeDeltaIfPresent = true
|
||||
};
|
||||
|
||||
var deltas = new List<string>();
|
||||
|
||||
// Act - Run 100 iterations
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var result = await _service.ComputeAsync(request);
|
||||
var serialized = JsonSerializer.Serialize(result.DeltaIfPresent);
|
||||
deltas.Add(serialized);
|
||||
}
|
||||
|
||||
// Assert - All deltas should be identical
|
||||
deltas.Should().AllSatisfy(delta => delta.Should().Be(deltas[0]));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Weight Manifest Hash Stability Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_ManifestHashStable_AcrossComputations()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateDeterministicRequest();
|
||||
var hashes = new List<string>();
|
||||
|
||||
// Act - Run multiple iterations
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var result = await _service.ComputeAsync(request);
|
||||
hashes.Add(result.WeightManifestRef.ContentHash);
|
||||
}
|
||||
|
||||
// Assert - All manifest hashes should be identical
|
||||
hashes.Should().AllSatisfy(hash => hash.Should().Be(hashes[0]));
|
||||
hashes[0].Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WeightManifest_HashStable_ForSameWeights()
|
||||
{
|
||||
// Arrange
|
||||
var weights = EvidenceWeights.Default;
|
||||
|
||||
// Act - Create manifests multiple times
|
||||
var hashes = new List<string>();
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var manifest = WeightManifest.FromEvidenceWeights(weights, $"v-test-{i}");
|
||||
hashes.Add(manifest.ContentHash);
|
||||
}
|
||||
|
||||
// Assert - All content hashes should be identical (version doesn't affect content hash)
|
||||
hashes.Should().AllSatisfy(hash => hash.Should().Be(hashes[0]));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EWS Passthrough Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_EwsScoreUnchanged_ThroughFacade()
|
||||
{
|
||||
// Arrange
|
||||
var input = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.73,
|
||||
Rts = 0.62,
|
||||
Bkp = 0.41,
|
||||
Xpl = 0.58,
|
||||
Src = 0.35,
|
||||
Mit = 0.22
|
||||
};
|
||||
|
||||
var policy = EvidenceWeightPolicy.FromWeights(EvidenceWeights.Default);
|
||||
var directResults = new List<double>();
|
||||
var facadeResults = new List<double>();
|
||||
|
||||
// Act - Run both direct and facade calculations
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
var directResult = _ewsCalculator.Calculate(input, policy);
|
||||
directResults.Add(directResult.Score);
|
||||
|
||||
var request = new UnifiedScoreRequest { EwsInput = input };
|
||||
var facadeResult = await _service.ComputeAsync(request);
|
||||
facadeResults.Add(facadeResult.Score);
|
||||
}
|
||||
|
||||
// Assert - All results should match
|
||||
directResults.Should().AllSatisfy(score => score.Should().Be(directResults[0]));
|
||||
facadeResults.Should().AllSatisfy(score => score.Should().Be(directResults[0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_EwsDigestUnchanged_ThroughFacade()
|
||||
{
|
||||
// Arrange
|
||||
var input = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.65,
|
||||
Rts = 0.55,
|
||||
Bkp = 0.45,
|
||||
Xpl = 0.60,
|
||||
Src = 0.40,
|
||||
Mit = 0.15
|
||||
};
|
||||
|
||||
var policy = EvidenceWeightPolicy.FromWeights(EvidenceWeights.Default);
|
||||
var directDigest = _ewsCalculator.Calculate(input, policy).ComputeDigest();
|
||||
|
||||
// Act
|
||||
var request = new UnifiedScoreRequest { EwsInput = input };
|
||||
var facadeResult = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
facadeResult.EwsDigest.Should().Be(directDigest);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Entropy Calculation Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_EntropyCalculationDeterministic_100Iterations()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.0, Rts = 0.0, Bkp = 0.0,
|
||||
Xpl = 0.0, Src = 0.0, Mit = 0.0
|
||||
},
|
||||
SignalSnapshot = new SignalSnapshot
|
||||
{
|
||||
Vex = SignalState.Present(),
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.NotQueried(),
|
||||
Runtime = SignalState.NotQueried(),
|
||||
Backport = SignalState.Present(),
|
||||
Sbom = SignalState.NotQueried(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
var entropies = new List<double>();
|
||||
|
||||
// Act - Run 100 iterations
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var result = await _service.ComputeAsync(request);
|
||||
entropies.Add(result.UnknownsFraction ?? -1);
|
||||
}
|
||||
|
||||
// Assert - All entropy values should be identical
|
||||
entropies.Should().AllSatisfy(e => e.Should().Be(entropies[0]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_UnknownsBandDeterministic_100Iterations()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.0, Rts = 0.0, Bkp = 0.0,
|
||||
Xpl = 0.0, Src = 0.0, Mit = 0.0
|
||||
},
|
||||
SignalSnapshot = new SignalSnapshot
|
||||
{
|
||||
Vex = SignalState.Present(),
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.NotQueried(),
|
||||
Runtime = SignalState.Present(),
|
||||
Backport = SignalState.Present(),
|
||||
Sbom = SignalState.Present(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
}
|
||||
};
|
||||
|
||||
var bands = new List<UnknownsBand>();
|
||||
|
||||
// Act - Run 100 iterations
|
||||
for (int i = 0; i < 100; i++)
|
||||
{
|
||||
var result = await _service.ComputeAsync(request);
|
||||
bands.Add(result.UnknownsBand ?? UnknownsBand.Complete);
|
||||
}
|
||||
|
||||
// Assert - All bands should be identical
|
||||
bands.Should().AllSatisfy(band => band.Should().Be(bands[0]));
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Parallel Computation Determinism Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_ParallelComputations_ProduceSameResults()
|
||||
{
|
||||
// Arrange
|
||||
var request = CreateDeterministicRequest();
|
||||
|
||||
// Act - Run 50 parallel computations
|
||||
var tasks = Enumerable.Range(0, 50)
|
||||
.Select(_ => _service.ComputeAsync(request))
|
||||
.ToArray();
|
||||
|
||||
var results = await Task.WhenAll(tasks);
|
||||
|
||||
// Assert - All parallel results should be identical
|
||||
var firstScore = results[0].Score;
|
||||
var firstDigest = results[0].EwsDigest;
|
||||
var firstBucket = results[0].Bucket;
|
||||
|
||||
results.Should().AllSatisfy(r =>
|
||||
{
|
||||
r.Score.Should().Be(firstScore);
|
||||
r.EwsDigest.Should().Be(firstDigest);
|
||||
r.Bucket.Should().Be(firstBucket);
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Golden Fixture Verification Tests
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(GoldenFixtureData))]
|
||||
public async Task ComputeAsync_MatchesGoldenFixture(
|
||||
string fixtureName,
|
||||
EvidenceWeightedScoreInput input,
|
||||
SignalSnapshot? snapshot,
|
||||
double expectedScore,
|
||||
ScoreBucket expectedBucket,
|
||||
double? expectedEntropy,
|
||||
UnknownsBand? expectedBand)
|
||||
{
|
||||
// Arrange
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = input,
|
||||
SignalSnapshot = snapshot
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
((double)result.Score).Should().BeApproximately(expectedScore, 1.0, because: $"fixture {fixtureName}");
|
||||
result.Bucket.Should().Be(expectedBucket, because: $"fixture {fixtureName}");
|
||||
|
||||
if (expectedEntropy.HasValue)
|
||||
{
|
||||
result.UnknownsFraction.Should().BeApproximately(expectedEntropy.Value, 0.01, because: $"fixture {fixtureName}");
|
||||
}
|
||||
|
||||
if (expectedBand.HasValue)
|
||||
{
|
||||
result.UnknownsBand.Should().Be(expectedBand.Value, because: $"fixture {fixtureName}");
|
||||
}
|
||||
}
|
||||
|
||||
public static IEnumerable<object?[]> GoldenFixtureData()
|
||||
{
|
||||
// Fixture 1: High-risk scenario (ActNow)
|
||||
yield return new object?[]
|
||||
{
|
||||
"high_risk_act_now",
|
||||
new EvidenceWeightedScoreInput { FindingId = "CVE-2024-0001@pkg:npm/test", Rch = 1.0, Rts = 1.0, Bkp = 0.0, Xpl = 1.0, Src = 1.0, Mit = 0.0 },
|
||||
SignalSnapshot.AllPresent(),
|
||||
95.0, // Expected high score
|
||||
ScoreBucket.ActNow,
|
||||
0.0, // All signals present
|
||||
UnknownsBand.Complete
|
||||
};
|
||||
|
||||
// Fixture 2: Low-risk scenario (Watchlist)
|
||||
yield return new object?[]
|
||||
{
|
||||
"low_risk_watchlist",
|
||||
new EvidenceWeightedScoreInput { FindingId = "CVE-2024-0001@pkg:npm/test", Rch = 0.0, Rts = 0.0, Bkp = 1.0, Xpl = 0.0, Src = 0.0, Mit = 1.0 },
|
||||
SignalSnapshot.AllPresent(),
|
||||
5.0, // Expected low score
|
||||
ScoreBucket.Watchlist,
|
||||
0.0,
|
||||
UnknownsBand.Complete
|
||||
};
|
||||
|
||||
// Fixture 3: Sparse signals scenario
|
||||
yield return new object?[]
|
||||
{
|
||||
"sparse_signals",
|
||||
new EvidenceWeightedScoreInput { FindingId = "CVE-2024-0001@pkg:npm/test", Rch = 0.5, Rts = 0.5, Bkp = 0.5, Xpl = 0.5, Src = 0.5, Mit = 0.0 },
|
||||
new SignalSnapshot
|
||||
{
|
||||
Vex = SignalState.NotQueried(),
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.NotQueried(),
|
||||
Runtime = SignalState.NotQueried(),
|
||||
Backport = SignalState.Present(),
|
||||
Sbom = SignalState.Present(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
50.0, // Mid-range score
|
||||
ScoreBucket.ScheduleNext,
|
||||
0.5, // 3 of 6 signals missing
|
||||
UnknownsBand.Sparse
|
||||
};
|
||||
|
||||
// Fixture 4: Insufficient signals scenario
|
||||
yield return new object?[]
|
||||
{
|
||||
"insufficient_signals",
|
||||
new EvidenceWeightedScoreInput { FindingId = "CVE-2024-0001@pkg:npm/test", Rch = 0.5, Rts = 0.5, Bkp = 0.5, Xpl = 0.5, Src = 0.5, Mit = 0.0 },
|
||||
SignalSnapshot.AllMissing(),
|
||||
50.0,
|
||||
ScoreBucket.ScheduleNext,
|
||||
1.0, // All signals missing
|
||||
UnknownsBand.Insufficient
|
||||
};
|
||||
|
||||
// Fixture 5: Adequate signals scenario
|
||||
yield return new object?[]
|
||||
{
|
||||
"adequate_signals",
|
||||
new EvidenceWeightedScoreInput { FindingId = "CVE-2024-0001@pkg:npm/test", Rch = 0.7, Rts = 0.6, Bkp = 0.3, Xpl = 0.5, Src = 0.4, Mit = 0.1 },
|
||||
new SignalSnapshot
|
||||
{
|
||||
Vex = SignalState.Present(),
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.Present(),
|
||||
Runtime = SignalState.Present(),
|
||||
Backport = SignalState.NotQueried(),
|
||||
Sbom = SignalState.Present(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
},
|
||||
60.0,
|
||||
ScoreBucket.ScheduleNext,
|
||||
1.0/6, // 1 of 6 signals missing
|
||||
UnknownsBand.Complete
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Helper Methods
|
||||
|
||||
private static UnifiedScoreRequest CreateDeterministicRequest()
|
||||
{
|
||||
return new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.75,
|
||||
Rts = 0.65,
|
||||
Bkp = 0.45,
|
||||
Xpl = 0.55,
|
||||
Src = 0.35,
|
||||
Mit = 0.15
|
||||
},
|
||||
SignalSnapshot = SignalSnapshot.AllPresent()
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-002 - Unified Score Facade Service
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using NSubstitute;
|
||||
using StellaOps.Signals.EvidenceWeightedScore;
|
||||
using StellaOps.Signals.UnifiedScore;
|
||||
|
||||
namespace StellaOps.Signals.Tests.UnifiedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for UnifiedScoreService.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnifiedScoreServiceTests
|
||||
{
|
||||
private readonly IEvidenceWeightedScoreCalculator _ewsCalculator;
|
||||
private readonly IWeightManifestLoader _manifestLoader;
|
||||
private readonly UnifiedScoreService _service;
|
||||
|
||||
public UnifiedScoreServiceTests()
|
||||
{
|
||||
_ewsCalculator = new EvidenceWeightedScoreCalculator();
|
||||
_manifestLoader = Substitute.For<IWeightManifestLoader>();
|
||||
|
||||
// Setup default manifest
|
||||
var defaultManifest = WeightManifest.FromEvidenceWeights(EvidenceWeights.Default, "v-test");
|
||||
_manifestLoader
|
||||
.LoadLatestAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(defaultManifest);
|
||||
_manifestLoader
|
||||
.LoadAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(defaultManifest);
|
||||
|
||||
_service = new UnifiedScoreService(
|
||||
_ewsCalculator,
|
||||
_manifestLoader,
|
||||
NullLogger<UnifiedScoreService>.Instance);
|
||||
}
|
||||
|
||||
#region Basic Computation Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_WithValidInput_ReturnsResult()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.8,
|
||||
Rts = 0.7,
|
||||
Bkp = 0.5,
|
||||
Xpl = 0.3,
|
||||
Src = 0.6,
|
||||
Mit = 0.1
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Score.Should().BeInRange(0, 100);
|
||||
result.Breakdown.Should().NotBeEmpty();
|
||||
result.EwsDigest.Should().NotBeNullOrEmpty();
|
||||
result.WeightManifestRef.Should().NotBeNull();
|
||||
result.ComputedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_WithSignalSnapshot_IncludesEntropy()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.5,
|
||||
Rts = 0.5,
|
||||
Bkp = 0.5,
|
||||
Xpl = 0.5,
|
||||
Src = 0.5,
|
||||
Mit = 0.1
|
||||
},
|
||||
SignalSnapshot = SignalSnapshot.AllPresent()
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.UnknownsFraction.Should().NotBeNull();
|
||||
result.UnknownsFraction.Should().Be(0.0); // All signals present
|
||||
result.UnknownsBand.Should().Be(UnknownsBand.Complete);
|
||||
result.DeterminizationFingerprint.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_WithMissingSignals_IncludesDeltaIfPresent()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.5,
|
||||
Rts = 0.5,
|
||||
Bkp = 0.5,
|
||||
Xpl = 0.5,
|
||||
Src = 0.5,
|
||||
Mit = 0.1
|
||||
},
|
||||
SignalSnapshot = SignalSnapshot.AllMissing(),
|
||||
IncludeDeltaIfPresent = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.UnknownsFraction.Should().Be(1.0); // All signals missing
|
||||
result.UnknownsBand.Should().Be(UnknownsBand.Insufficient);
|
||||
result.DeltaIfPresent.Should().NotBeNull();
|
||||
result.DeltaIfPresent.Should().HaveCountGreaterThan(0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Score Bucket Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_HighScore_ReturnsActNowBucket()
|
||||
{
|
||||
// Arrange - High values for all positive signals
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 1.0,
|
||||
Rts = 1.0,
|
||||
Bkp = 0.0, // Backport not available = vulnerable
|
||||
Xpl = 1.0,
|
||||
Src = 1.0,
|
||||
Mit = 0.0
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Score.Should().BeGreaterThanOrEqualTo(90);
|
||||
result.Bucket.Should().Be(ScoreBucket.ActNow);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_LowScore_ReturnsWatchlistBucket()
|
||||
{
|
||||
// Arrange - High mitigation
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.0, // Not reachable
|
||||
Rts = 0.0, // No runtime evidence
|
||||
Bkp = 1.0, // Backport available
|
||||
Xpl = 0.0,
|
||||
Src = 0.0,
|
||||
Mit = 1.0 // Fully mitigated
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Score.Should().BeLessThan(40);
|
||||
result.Bucket.Should().Be(ScoreBucket.Watchlist);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Unknowns Band Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, UnknownsBand.Complete)]
|
||||
[InlineData(0.15, UnknownsBand.Complete)]
|
||||
[InlineData(0.25, UnknownsBand.Adequate)]
|
||||
[InlineData(0.35, UnknownsBand.Adequate)]
|
||||
[InlineData(0.45, UnknownsBand.Sparse)]
|
||||
[InlineData(0.55, UnknownsBand.Sparse)]
|
||||
[InlineData(0.65, UnknownsBand.Insufficient)]
|
||||
[InlineData(1.0, UnknownsBand.Insufficient)]
|
||||
public async Task ComputeAsync_MapsEntropyToBandCorrectly(double expectedEntropy, UnknownsBand expectedBand)
|
||||
{
|
||||
// Arrange - Create snapshot with appropriate number of missing signals
|
||||
var snapshot = new SignalSnapshot
|
||||
{
|
||||
Vex = expectedEntropy >= 1.0/6 ? SignalState.NotQueried() : SignalState.Present(),
|
||||
Epss = expectedEntropy >= 2.0/6 ? SignalState.NotQueried() : SignalState.Present(),
|
||||
Reachability = expectedEntropy >= 3.0/6 ? SignalState.NotQueried() : SignalState.Present(),
|
||||
Runtime = expectedEntropy >= 4.0/6 ? SignalState.NotQueried() : SignalState.Present(),
|
||||
Backport = expectedEntropy >= 5.0/6 ? SignalState.NotQueried() : SignalState.Present(),
|
||||
Sbom = expectedEntropy >= 6.0/6 ? SignalState.NotQueried() : SignalState.Present(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.0, Rts = 0.0, Bkp = 0.0,
|
||||
Xpl = 0.0, Src = 0.0, Mit = 0.0
|
||||
},
|
||||
SignalSnapshot = snapshot
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.UnknownsBand.Should().Be(expectedBand);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Conflict Detection Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_WithConflictingSignals_DetectsConflict()
|
||||
{
|
||||
// Arrange - High reachability AND high backport (unusual combination)
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.95,
|
||||
Rts = 0.5,
|
||||
Bkp = 0.95,
|
||||
Xpl = 0.5,
|
||||
Src = 0.5,
|
||||
Mit = 0.1
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.Conflicts.Should().NotBeNull();
|
||||
result.Conflicts.Should().HaveCountGreaterThan(0);
|
||||
result.Conflicts!.Should().Contain(c => c.SignalA == "Reachability" && c.SignalB == "Backport");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region EWS Score Passthrough Tests
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_EwsScorePassedThrough_MatchesDirectCalculation()
|
||||
{
|
||||
// Arrange
|
||||
var input = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.7,
|
||||
Rts = 0.6,
|
||||
Bkp = 0.4,
|
||||
Xpl = 0.5,
|
||||
Src = 0.3,
|
||||
Mit = 0.2
|
||||
};
|
||||
|
||||
var policy = EvidenceWeightPolicy.FromWeights(EvidenceWeights.Default);
|
||||
var directResult = _ewsCalculator.Calculate(input, policy);
|
||||
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = input
|
||||
};
|
||||
|
||||
// Act
|
||||
var unifiedResult = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
unifiedResult.Score.Should().Be(directResult.Score);
|
||||
unifiedResult.Bucket.Should().Be(directResult.Bucket);
|
||||
unifiedResult.EwsDigest.Should().Be(directResult.ComputeDigest());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Synchronous Compute Tests
|
||||
|
||||
[Fact]
|
||||
public void Compute_SyncVersion_ReturnsCorrectResult()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.5,
|
||||
Rts = 0.5,
|
||||
Bkp = 0.5,
|
||||
Xpl = 0.5,
|
||||
Src = 0.5,
|
||||
Mit = 0.1
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = _service.Compute(request);
|
||||
|
||||
// Assert
|
||||
result.Should().NotBeNull();
|
||||
result.Score.Should().BeInRange(0, 100);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Delta-If-Present Tests (TSF-004)
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_WithMissingReachability_IncludesDelta()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new SignalSnapshot
|
||||
{
|
||||
Vex = SignalState.Present(),
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.NotQueried(), // Missing
|
||||
Runtime = SignalState.Present(),
|
||||
Backport = SignalState.Present(),
|
||||
Sbom = SignalState.Present(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.0, Rts = 0.0, Bkp = 0.0,
|
||||
Xpl = 0.0, Src = 0.0, Mit = 0.0
|
||||
},
|
||||
SignalSnapshot = snapshot,
|
||||
IncludeDeltaIfPresent = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.DeltaIfPresent.Should().NotBeNull();
|
||||
result.DeltaIfPresent.Should().Contain(d => d.Signal == "Reachability");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_WithMissingRuntime_IncludesDelta()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new SignalSnapshot
|
||||
{
|
||||
Vex = SignalState.Present(),
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.Present(),
|
||||
Runtime = SignalState.NotQueried(), // Missing
|
||||
Backport = SignalState.Present(),
|
||||
Sbom = SignalState.Present(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.0, Rts = 0.0, Bkp = 0.0,
|
||||
Xpl = 0.0, Src = 0.0, Mit = 0.0
|
||||
},
|
||||
SignalSnapshot = snapshot,
|
||||
IncludeDeltaIfPresent = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.DeltaIfPresent.Should().NotBeNull();
|
||||
result.DeltaIfPresent.Should().Contain(d => d.Signal == "Runtime");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_WithMultipleMissingSignals_IncludesAllDeltas()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new SignalSnapshot
|
||||
{
|
||||
Vex = SignalState.NotQueried(), // Missing
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.NotQueried(), // Missing
|
||||
Runtime = SignalState.NotQueried(), // Missing
|
||||
Backport = SignalState.NotQueried(), // Missing
|
||||
Sbom = SignalState.Present(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.0, Rts = 0.0, Bkp = 0.0,
|
||||
Xpl = 0.0, Src = 0.0, Mit = 0.0
|
||||
},
|
||||
SignalSnapshot = snapshot,
|
||||
IncludeDeltaIfPresent = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.DeltaIfPresent.Should().NotBeNull();
|
||||
result.DeltaIfPresent.Should().HaveCountGreaterThanOrEqualTo(4);
|
||||
result.DeltaIfPresent.Should().Contain(d => d.Signal == "VEX");
|
||||
result.DeltaIfPresent.Should().Contain(d => d.Signal == "Reachability");
|
||||
result.DeltaIfPresent.Should().Contain(d => d.Signal == "Runtime");
|
||||
result.DeltaIfPresent.Should().Contain(d => d.Signal == "Backport");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_DeltaIfPresentDisabled_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.0, Rts = 0.0, Bkp = 0.0,
|
||||
Xpl = 0.0, Src = 0.0, Mit = 0.0
|
||||
},
|
||||
SignalSnapshot = SignalSnapshot.AllMissing(),
|
||||
IncludeDeltaIfPresent = false // Disabled
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.DeltaIfPresent.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_AllSignalsPresent_NoDeltasReturned()
|
||||
{
|
||||
// Arrange
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.0, Rts = 0.0, Bkp = 0.0,
|
||||
Xpl = 0.0, Src = 0.0, Mit = 0.0
|
||||
},
|
||||
SignalSnapshot = SignalSnapshot.AllPresent(),
|
||||
IncludeDeltaIfPresent = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
result.DeltaIfPresent.Should().NotBeNull();
|
||||
result.DeltaIfPresent.Should().BeEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_DeltaIncludesWeights_FromManifest()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new SignalSnapshot
|
||||
{
|
||||
Vex = SignalState.Present(),
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.NotQueried(),
|
||||
Runtime = SignalState.Present(),
|
||||
Backport = SignalState.Present(),
|
||||
Sbom = SignalState.Present(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.0, Rts = 0.0, Bkp = 0.0,
|
||||
Xpl = 0.0, Src = 0.0, Mit = 0.0
|
||||
},
|
||||
SignalSnapshot = snapshot,
|
||||
IncludeDeltaIfPresent = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
var reachabilityDelta = result.DeltaIfPresent?.FirstOrDefault(d => d.Signal == "Reachability");
|
||||
reachabilityDelta.Should().NotBeNull();
|
||||
reachabilityDelta!.Weight.Should().Be(0.30); // Default RCH weight
|
||||
reachabilityDelta.MaxImpact.Should().BeGreaterThan(0);
|
||||
reachabilityDelta.Description.Should().NotBeNullOrEmpty();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ComputeAsync_VexDelta_ShowsReductionPotential()
|
||||
{
|
||||
// Arrange
|
||||
var snapshot = new SignalSnapshot
|
||||
{
|
||||
Vex = SignalState.NotQueried(), // VEX missing
|
||||
Epss = SignalState.Present(),
|
||||
Reachability = SignalState.Present(),
|
||||
Runtime = SignalState.Present(),
|
||||
Backport = SignalState.Present(),
|
||||
Sbom = SignalState.Present(),
|
||||
SnapshotAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
var request = new UnifiedScoreRequest
|
||||
{
|
||||
EwsInput = new EvidenceWeightedScoreInput
|
||||
{
|
||||
FindingId = "CVE-2024-0001@pkg:npm/test",
|
||||
Rch = 0.8,
|
||||
Rts = 0.7,
|
||||
Bkp = 0.0,
|
||||
Xpl = 0.5,
|
||||
Src = 0.5,
|
||||
Mit = 0.0
|
||||
},
|
||||
SignalSnapshot = snapshot,
|
||||
IncludeDeltaIfPresent = true
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = await _service.ComputeAsync(request);
|
||||
|
||||
// Assert
|
||||
var vexDelta = result.DeltaIfPresent?.FirstOrDefault(d => d.Signal == "VEX");
|
||||
vexDelta.Should().NotBeNull();
|
||||
vexDelta!.MinImpact.Should().BeLessThan(0); // VEX can reduce score
|
||||
vexDelta.Description.Should().Contain("not_affected");
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// SPDX-License-Identifier: BUSL-1.1
|
||||
// Copyright (c) 2025 StellaOps
|
||||
// Sprint: SPRINT_20260122_037_Signals_unified_trust_score_algebra
|
||||
// Task: TSF-003 - Unknowns Band Mapping
|
||||
|
||||
using FluentAssertions;
|
||||
using StellaOps.Signals.UnifiedScore;
|
||||
|
||||
namespace StellaOps.Signals.Tests.UnifiedScore;
|
||||
|
||||
/// <summary>
|
||||
/// Unit tests for UnknownsBandMapper.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class UnknownsBandMapperTests
|
||||
{
|
||||
private readonly UnknownsBandMapper _mapper;
|
||||
|
||||
public UnknownsBandMapperTests()
|
||||
{
|
||||
_mapper = new UnknownsBandMapper();
|
||||
}
|
||||
|
||||
#region Band Mapping Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, UnknownsBand.Complete)]
|
||||
[InlineData(0.1, UnknownsBand.Complete)]
|
||||
[InlineData(0.19, UnknownsBand.Complete)]
|
||||
public void MapEntropyToBand_LowEntropy_ReturnsComplete(double entropy, UnknownsBand expected)
|
||||
{
|
||||
_mapper.MapEntropyToBand(entropy).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.2, UnknownsBand.Adequate)]
|
||||
[InlineData(0.3, UnknownsBand.Adequate)]
|
||||
[InlineData(0.39, UnknownsBand.Adequate)]
|
||||
public void MapEntropyToBand_ModerateEntropy_ReturnsAdequate(double entropy, UnknownsBand expected)
|
||||
{
|
||||
_mapper.MapEntropyToBand(entropy).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.4, UnknownsBand.Sparse)]
|
||||
[InlineData(0.5, UnknownsBand.Sparse)]
|
||||
[InlineData(0.59, UnknownsBand.Sparse)]
|
||||
public void MapEntropyToBand_HighEntropy_ReturnsSparse(double entropy, UnknownsBand expected)
|
||||
{
|
||||
_mapper.MapEntropyToBand(entropy).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.6, UnknownsBand.Insufficient)]
|
||||
[InlineData(0.8, UnknownsBand.Insufficient)]
|
||||
[InlineData(1.0, UnknownsBand.Insufficient)]
|
||||
public void MapEntropyToBand_VeryHighEntropy_ReturnsInsufficient(double entropy, UnknownsBand expected)
|
||||
{
|
||||
_mapper.MapEntropyToBand(entropy).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-0.5)]
|
||||
[InlineData(1.5)]
|
||||
public void MapEntropyToBand_OutOfRangeEntropy_ClampsAndMaps(double entropy)
|
||||
{
|
||||
// Should not throw, should clamp
|
||||
var result = _mapper.MapEntropyToBand(entropy);
|
||||
result.Should().BeOneOf(UnknownsBand.Complete, UnknownsBand.Insufficient);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Description Tests
|
||||
|
||||
[Fact]
|
||||
public void GetBandDescription_AllBands_ReturnsMeaningfulDescriptions()
|
||||
{
|
||||
foreach (UnknownsBand band in Enum.GetValues<UnknownsBand>())
|
||||
{
|
||||
var description = _mapper.GetBandDescription(band);
|
||||
description.Should().NotBeNullOrEmpty();
|
||||
description.Length.Should().BeGreaterThan(10);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetBandAction_AllBands_ReturnsMeaningfulActions()
|
||||
{
|
||||
foreach (UnknownsBand band in Enum.GetValues<UnknownsBand>())
|
||||
{
|
||||
var action = _mapper.GetBandAction(band);
|
||||
action.Should().NotBeNullOrEmpty();
|
||||
action.Length.Should().BeGreaterThan(10);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Automation Safety Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, true)]
|
||||
[InlineData(0.1, true)]
|
||||
[InlineData(0.3, true)]
|
||||
[InlineData(0.39, true)]
|
||||
public void IsAutomationSafe_LowEntropy_ReturnsTrue(double entropy, bool expected)
|
||||
{
|
||||
_mapper.IsAutomationSafe(entropy).Should().Be(expected);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.4, false)]
|
||||
[InlineData(0.6, false)]
|
||||
[InlineData(1.0, false)]
|
||||
public void IsAutomationSafe_HighEntropy_ReturnsFalse(double entropy, bool expected)
|
||||
{
|
||||
_mapper.IsAutomationSafe(entropy).Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Manual Review Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, false)]
|
||||
[InlineData(0.3, false)]
|
||||
[InlineData(0.4, true)]
|
||||
[InlineData(0.6, true)]
|
||||
[InlineData(1.0, true)]
|
||||
public void RequiresManualReview_VariousEntropy_ReturnsExpected(double entropy, bool expected)
|
||||
{
|
||||
_mapper.RequiresManualReview(entropy).Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Block Decision Tests
|
||||
|
||||
[Theory]
|
||||
[InlineData(0.0, false)]
|
||||
[InlineData(0.4, false)]
|
||||
[InlineData(0.59, false)]
|
||||
[InlineData(0.6, true)]
|
||||
[InlineData(1.0, true)]
|
||||
public void ShouldBlock_VariousEntropy_ReturnsExpected(double entropy, bool expected)
|
||||
{
|
||||
_mapper.ShouldBlock(entropy).Should().Be(expected);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Custom Threshold Tests
|
||||
|
||||
[Fact]
|
||||
public void MapEntropyToBand_WithCustomThresholds_UsesCustomValues()
|
||||
{
|
||||
// Arrange - Custom thresholds
|
||||
var options = new UnknownsBandMapperOptions
|
||||
{
|
||||
CompleteThreshold = 0.1,
|
||||
AdequateThreshold = 0.3,
|
||||
SparseThreshold = 0.5
|
||||
};
|
||||
var customMapper = new UnknownsBandMapper(options);
|
||||
|
||||
// Act & Assert
|
||||
customMapper.MapEntropyToBand(0.05).Should().Be(UnknownsBand.Complete);
|
||||
customMapper.MapEntropyToBand(0.15).Should().Be(UnknownsBand.Adequate);
|
||||
customMapper.MapEntropyToBand(0.35).Should().Be(UnknownsBand.Sparse);
|
||||
customMapper.MapEntropyToBand(0.55).Should().Be(UnknownsBand.Insufficient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void FromDeterminizationThresholds_CreatesMatchingOptions()
|
||||
{
|
||||
// Arrange
|
||||
var options = UnknownsBandMapperOptions.FromDeterminizationThresholds(
|
||||
manualReviewThreshold: 0.55,
|
||||
refreshThreshold: 0.35);
|
||||
|
||||
// Assert
|
||||
options.CompleteThreshold.Should().Be(0.2);
|
||||
options.AdequateThreshold.Should().Be(0.35);
|
||||
options.SparseThreshold.Should().Be(0.55);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Threshold Query Tests
|
||||
|
||||
[Fact]
|
||||
public void GetThreshold_ReturnsConfiguredThresholds()
|
||||
{
|
||||
_mapper.GetThreshold(UnknownsBand.Complete).Should().Be(0.2);
|
||||
_mapper.GetThreshold(UnknownsBand.Adequate).Should().Be(0.4);
|
||||
_mapper.GetThreshold(UnknownsBand.Sparse).Should().Be(0.6);
|
||||
_mapper.GetThreshold(UnknownsBand.Insufficient).Should().Be(1.0);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Boundary Tests
|
||||
|
||||
[Fact]
|
||||
public void MapEntropyToBand_ExactBoundaries_MapsCorrectly()
|
||||
{
|
||||
// Test exact boundary values
|
||||
_mapper.MapEntropyToBand(0.2).Should().Be(UnknownsBand.Adequate); // Exactly at Complete/Adequate boundary
|
||||
_mapper.MapEntropyToBand(0.4).Should().Be(UnknownsBand.Sparse); // Exactly at Adequate/Sparse boundary
|
||||
_mapper.MapEntropyToBand(0.6).Should().Be(UnknownsBand.Insufficient); // Exactly at Sparse/Insufficient boundary
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
Reference in New Issue
Block a user