Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.Reachability.Tests/ReachabilityCacheTests.cs
2026-01-08 20:46:43 +02:00

367 lines
14 KiB
C#

// -----------------------------------------------------------------------------
// ReachabilityCacheTests.cs
// Sprint: SPRINT_3700_0006_0001_incremental_cache (CACHE-016, CACHE-017)
// Description: Unit tests for reachability cache components.
// -----------------------------------------------------------------------------
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.Scanner.Reachability.Cache;
using Xunit;
using StellaOps.TestKit;
namespace StellaOps.Scanner.Reachability.Tests;
public sealed class GraphDeltaComputerTests
{
private readonly GraphDeltaComputer _computer;
private static CancellationToken TestCancellationToken => TestContext.Current.CancellationToken;
public GraphDeltaComputerTests()
{
_computer = new GraphDeltaComputer(NullLogger<GraphDeltaComputer>.Instance);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ComputeDeltaAsync_SameHash_ReturnsEmpty()
{
// Arrange
var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B" }, new[] { ("A", "B") });
var graph2 = new TestGraphSnapshot("hash1", new[] { "A", "B" }, new[] { ("A", "B") });
// Act
var delta = await _computer.ComputeDeltaAsync(graph1, graph2, TestCancellationToken);
// Assert
delta.HasChanges.Should().BeFalse();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ComputeDeltaAsync_AddedNode_ReturnsCorrectDelta()
{
// Arrange
var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B" }, new[] { ("A", "B") });
var graph2 = new TestGraphSnapshot("hash2", new[] { "A", "B", "C" }, new[] { ("A", "B"), ("B", "C") });
// Act
var delta = await _computer.ComputeDeltaAsync(graph1, graph2, TestCancellationToken);
// Assert
delta.HasChanges.Should().BeTrue();
delta.AddedNodes.Should().Contain("C");
delta.RemovedNodes.Should().BeEmpty();
delta.AddedEdges.Should().ContainSingle(e => e.CallerKey == "B" && e.CalleeKey == "C");
delta.AffectedMethodKeys.Should().Contain("C");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ComputeDeltaAsync_RemovedNode_ReturnsCorrectDelta()
{
// Arrange
var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B", "C" }, new[] { ("A", "B"), ("B", "C") });
var graph2 = new TestGraphSnapshot("hash2", new[] { "A", "B" }, new[] { ("A", "B") });
// Act
var delta = await _computer.ComputeDeltaAsync(graph1, graph2, TestCancellationToken);
// Assert
delta.HasChanges.Should().BeTrue();
delta.RemovedNodes.Should().Contain("C");
delta.AddedNodes.Should().BeEmpty();
delta.RemovedEdges.Should().ContainSingle(e => e.CallerKey == "B" && e.CalleeKey == "C");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task ComputeDeltaAsync_EdgeChange_DetectsAffectedMethods()
{
// Arrange
var graph1 = new TestGraphSnapshot("hash1", new[] { "A", "B", "C" }, new[] { ("A", "B") });
var graph2 = new TestGraphSnapshot("hash2", new[] { "A", "B", "C" }, new[] { ("A", "C") });
// Act
var delta = await _computer.ComputeDeltaAsync(graph1, graph2, TestCancellationToken);
// Assert
delta.HasChanges.Should().BeTrue();
delta.AddedEdges.Should().ContainSingle(e => e.CallerKey == "A" && e.CalleeKey == "C");
delta.RemovedEdges.Should().ContainSingle(e => e.CallerKey == "A" && e.CalleeKey == "B");
delta.AffectedMethodKeys.Should().Contain(new[] { "A", "B", "C" });
}
private sealed class TestGraphSnapshot : IGraphSnapshot
{
public string Hash { get; }
public IReadOnlySet<string> NodeKeys { get; }
public IReadOnlyList<Cache.GraphEdge> Edges { get; }
public IReadOnlySet<string> EntryPoints { get; }
public TestGraphSnapshot(string hash, string[] nodes, (string, string)[] edges, string[]? entryPoints = null)
{
Hash = hash;
NodeKeys = nodes.ToHashSet();
Edges = edges.Select(e => new Cache.GraphEdge(e.Item1, e.Item2)).ToList();
EntryPoints = (entryPoints ?? nodes.Take(1).ToArray()).ToHashSet();
}
}
}
public sealed class ImpactSetCalculatorTests
{
private readonly ImpactSetCalculator _calculator;
private static CancellationToken TestCancellationToken => TestContext.Current.CancellationToken;
public ImpactSetCalculatorTests()
{
_calculator = new ImpactSetCalculator(NullLogger<ImpactSetCalculator>.Instance);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CalculateImpactAsync_NoDelta_ReturnsEmpty()
{
// Arrange
var delta = GraphDelta.Empty;
var graph = new TestGraphSnapshot("hash1", new[] { "Entry", "A", "B" }, new[] { ("Entry", "A"), ("A", "B") });
// Act
var impact = await _calculator.CalculateImpactAsync(delta, graph, TestCancellationToken);
// Assert
impact.RequiresFullRecompute.Should().BeFalse();
impact.AffectedEntryPoints.Should().BeEmpty();
impact.SavingsRatio.Should().Be(1.0);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CalculateImpactAsync_ChangeInPath_IdentifiesAffectedEntry()
{
// Arrange
var delta = new GraphDelta
{
AddedNodes = new HashSet<string> { "C" },
AddedEdges = new List<Cache.GraphEdge> { new("B", "C") },
AffectedMethodKeys = new HashSet<string> { "B", "C" }
};
var graph = new TestGraphSnapshot(
"hash2",
new[] { "Entry", "Entry2", "Entry3", "Entry4", "A", "B", "C" },
new[] { ("Entry", "A"), ("A", "B"), ("B", "C") },
new[] { "Entry", "Entry2", "Entry3", "Entry4" });
// Act
var impact = await _calculator.CalculateImpactAsync(delta, graph, TestCancellationToken);
// Assert
impact.RequiresFullRecompute.Should().BeFalse();
impact.AffectedEntryPoints.Should().Contain("Entry");
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task CalculateImpactAsync_ManyAffected_TriggersFullRecompute()
{
// Arrange - More than 30% affected
var delta = new GraphDelta
{
AddedNodes = new HashSet<string> { "Entry1", "Entry2", "Entry3", "Entry4" },
AffectedMethodKeys = new HashSet<string> { "Entry1", "Entry2", "Entry3", "Entry4" }
};
var graph = new TestGraphSnapshot(
"hash2",
new[] { "Entry1", "Entry2", "Entry3", "Entry4", "Sink" },
new[] { ("Entry1", "Sink"), ("Entry2", "Sink"), ("Entry3", "Sink"), ("Entry4", "Sink") },
new[] { "Entry1", "Entry2", "Entry3", "Entry4" });
// Act
var impact = await _calculator.CalculateImpactAsync(delta, graph, TestCancellationToken);
// Assert - All 4 entries affected = 100% > 30% threshold
impact.RequiresFullRecompute.Should().BeTrue();
}
private sealed class TestGraphSnapshot : IGraphSnapshot
{
public string Hash { get; }
public IReadOnlySet<string> NodeKeys { get; }
public IReadOnlyList<Cache.GraphEdge> Edges { get; }
public IReadOnlySet<string> EntryPoints { get; }
public TestGraphSnapshot(string hash, string[] nodes, (string, string)[] edges, string[]? entryPoints = null)
{
Hash = hash;
NodeKeys = nodes.ToHashSet();
Edges = edges.Select(e => new Cache.GraphEdge(e.Item1, e.Item2)).ToList();
EntryPoints = (entryPoints ?? nodes.Take(1).ToArray()).ToHashSet();
}
}
}
public sealed class StateFlipDetectorTests
{
private readonly StateFlipDetector _detector;
private static CancellationToken TestCancellationToken => TestContext.Current.CancellationToken;
public StateFlipDetectorTests()
{
_detector = new StateFlipDetector(NullLogger<StateFlipDetector>.Instance);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DetectFlipsAsync_NoChanges_ReturnsEmpty()
{
// Arrange
var previous = new List<ReachablePairResult>
{
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
};
var current = new List<ReachablePairResult>
{
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
};
// Act
var result = await _detector.DetectFlipsAsync(previous, current, TestCancellationToken);
// Assert
result.HasFlips.Should().BeFalse();
result.NewRiskCount.Should().Be(0);
result.MitigatedCount.Should().Be(0);
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DetectFlipsAsync_BecameReachable_ReturnsNewRisk()
{
// Arrange
var previous = new List<ReachablePairResult>
{
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
};
var current = new List<ReachablePairResult>
{
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
};
// Act
var result = await _detector.DetectFlipsAsync(previous, current, TestCancellationToken);
// Assert
result.HasFlips.Should().BeTrue();
result.NewRiskCount.Should().Be(1);
result.MitigatedCount.Should().Be(0);
result.NewlyReachable.Should().ContainSingle()
.Which.FlipType.Should().Be(StateFlipType.BecameReachable);
result.ShouldBlockPr.Should().BeTrue();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DetectFlipsAsync_BecameUnreachable_ReturnsMitigated()
{
// Arrange
var previous = new List<ReachablePairResult>
{
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
};
var current = new List<ReachablePairResult>
{
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
};
// Act
var result = await _detector.DetectFlipsAsync(previous, current, TestCancellationToken);
// Assert
result.HasFlips.Should().BeTrue();
result.NewRiskCount.Should().Be(0);
result.MitigatedCount.Should().Be(1);
result.NewlyUnreachable.Should().ContainSingle()
.Which.FlipType.Should().Be(StateFlipType.BecameUnreachable);
result.ShouldBlockPr.Should().BeFalse();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DetectFlipsAsync_NewReachablePair_ReturnsNewRisk()
{
// Arrange
var previous = new List<ReachablePairResult>();
var current = new List<ReachablePairResult>
{
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
};
// Act
var result = await _detector.DetectFlipsAsync(previous, current, TestCancellationToken);
// Assert
result.HasFlips.Should().BeTrue();
result.NewRiskCount.Should().Be(1);
result.ShouldBlockPr.Should().BeTrue();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DetectFlipsAsync_RemovedReachablePair_ReturnsMitigated()
{
// Arrange
var previous = new List<ReachablePairResult>
{
new() { EntryMethodKey = "Entry", SinkMethodKey = "Sink", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
};
var current = new List<ReachablePairResult>();
// Act
var result = await _detector.DetectFlipsAsync(previous, current, TestCancellationToken);
// Assert
result.HasFlips.Should().BeTrue();
result.MitigatedCount.Should().Be(1);
result.ShouldBlockPr.Should().BeFalse();
}
[Trait("Category", TestCategories.Unit)]
[Fact]
public async Task DetectFlipsAsync_NetChange_CalculatesCorrectly()
{
// Arrange
var previous = new List<ReachablePairResult>
{
new() { EntryMethodKey = "E1", SinkMethodKey = "S1", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow },
new() { EntryMethodKey = "E2", SinkMethodKey = "S2", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
};
var current = new List<ReachablePairResult>
{
new() { EntryMethodKey = "E1", SinkMethodKey = "S1", IsReachable = false, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow },
new() { EntryMethodKey = "E2", SinkMethodKey = "S2", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow },
new() { EntryMethodKey = "E3", SinkMethodKey = "S3", IsReachable = true, Confidence = 1.0, ComputedAt = DateTimeOffset.UtcNow }
};
// Act
var result = await _detector.DetectFlipsAsync(previous, current, TestCancellationToken);
// Assert
result.NewRiskCount.Should().Be(2); // E2->S2 became reachable, E3->S3 new
result.MitigatedCount.Should().Be(1); // E1->S1 became unreachable
result.NetChange.Should().Be(1); // +2 - 1 = 1
}
}