finish off sprint advisories and sprints

This commit is contained in:
master
2026-01-24 00:12:43 +02:00
parent 726d70dc7f
commit c70e83719e
266 changed files with 46699 additions and 1328 deletions

View File

@@ -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
}

View File

@@ -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"
}
}
]
}

View File

@@ -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" />

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}