// Copyright (c) StellaOps. All rights reserved. // Licensed under BUSL-1.1. See LICENSE in the project root. using FluentAssertions; using StellaOps.Scanner.ChangeTrace.ByteDiff; namespace StellaOps.Scanner.ChangeTrace.Tests.ByteDiff; /// /// Tests for ByteLevelDiffer. /// [Trait("Category", "Unit")] public sealed class ByteLevelDifferTests { private readonly ByteLevelDiffer _differ = new(); [Fact] public async Task CompareAsync_IdenticalStreams_ReturnsNoDeltas() { // Arrange var data = CreateTestData(4096); using var fromStream = new MemoryStream(data); using var toStream = new MemoryStream(data); // Act var deltas = await _differ.CompareAsync(fromStream, toStream); // Assert deltas.Should().BeEmpty(); } [Fact] public async Task CompareAsync_DifferentStreams_ReturnsDeltas() { // Arrange var fromData = CreateTestData(4096); var toData = CreateTestData(4096, seed: 99); // Different seed = different data using var fromStream = new MemoryStream(fromData); using var toStream = new MemoryStream(toData); // Act var deltas = await _differ.CompareAsync(fromStream, toStream); // Assert deltas.Should().NotBeEmpty(); } [Fact] public async Task CompareAsync_PartialChange_DetectsChangedWindow() { // Arrange var fromData = CreateTestData(8192); var toData = (byte[])fromData.Clone(); // Modify only the second window (bytes 2048-4095) for (var i = 2048; i < 4096; i++) { toData[i] = (byte)(toData[i] ^ 0xFF); } using var fromStream = new MemoryStream(fromData); using var toStream = new MemoryStream(toData); // Act var deltas = await _differ.CompareAsync(fromStream, toStream); // Assert deltas.Should().HaveCount(1); deltas[0].Offset.Should().Be(2048); deltas[0].Size.Should().Be(2048); } [Fact] public async Task CompareAsync_TruncatedToFile_ReportsDeltas() { // Arrange var fromData = CreateTestData(8192); var toData = fromData[..4096]; // Truncated to half using var fromStream = new MemoryStream(fromData); using var toStream = new MemoryStream(toData); // Act var deltas = await _differ.CompareAsync(fromStream, toStream); // Assert deltas.Should().NotBeEmpty(); deltas.Should().Contain(d => d.ToHash == "truncated"); } [Fact] public async Task CompareAsync_WithOptions_UsesWindowSize() { // Arrange var fromData = CreateTestData(4096); var toData = (byte[])fromData.Clone(); // Modify only first 512 bytes for (var i = 0; i < 512; i++) { toData[i] = (byte)(toData[i] ^ 0xFF); } using var fromStream = new MemoryStream(fromData); using var toStream = new MemoryStream(toData); var options = new ByteDiffOptions { WindowSize = 1024, StepSize = 1024 }; // Act var deltas = await _differ.CompareAsync(fromStream, toStream, options); // Assert deltas.Should().HaveCount(1); deltas[0].Size.Should().Be(1024); // Reports full window even though only 512 changed } [Fact] public async Task CompareAsync_WithContext_IncludesContextDescription() { // Arrange var fromData = CreateTestData(4096); var toData = CreateTestData(4096, seed: 99); using var fromStream = new MemoryStream(fromData); using var toStream = new MemoryStream(toData); var options = new ByteDiffOptions { IncludeContext = true }; // Act var deltas = await _differ.CompareAsync(fromStream, toStream, options); // Assert // Context is included when there are multiple consecutive changes deltas.Should().NotBeEmpty(); } [Fact] public async Task CompareAsync_LargeFiles_UsesSampling() { // Arrange var size = 11 * 1024 * 1024; // 11MB - over default 10MB limit var fromData = new byte[size]; var toData = new byte[size]; new Random(42).NextBytes(fromData); new Random(43).NextBytes(toData); using var fromStream = new MemoryStream(fromData); using var toStream = new MemoryStream(toData); var options = new ByteDiffOptions { MaxFileSize = 10 * 1024 * 1024 }; // Act var deltas = await _differ.CompareAsync(fromStream, toStream, options); // Assert - Should complete without memory issues and return sampled deltas deltas.Should().NotBeEmpty(); } [Fact] public async Task CompareAsync_DisableSectionAnalysis_UsesFullBinaryComparison() { // Arrange var fromData = CreateTestData(4096); var toData = CreateTestData(4096, seed: 99); using var fromStream = new MemoryStream(fromData); using var toStream = new MemoryStream(toData); var options = new ByteDiffOptions { AnalyzeBySections = false }; // Act var deltas = await _differ.CompareAsync(fromStream, toStream, options); // Assert deltas.Should().NotBeEmpty(); deltas.Should().AllSatisfy(d => d.Section.Should().BeNull()); } [Fact] public async Task CompareAsync_CancellationToken_Respected() { // Arrange var fromData = CreateTestData(4096); var toData = CreateTestData(4096, seed: 99); using var fromStream = new MemoryStream(fromData); using var toStream = new MemoryStream(toData); var cts = new CancellationTokenSource(); cts.Cancel(); // Act & Assert var action = async () => await _differ.CompareAsync(fromStream, toStream, ct: cts.Token); await action.Should().ThrowAsync(); } [Fact] public async Task CompareAsync_NullFromStream_ThrowsArgumentNullException() { // Arrange using var toStream = new MemoryStream(); // Act & Assert var action = async () => await _differ.CompareAsync(null!, toStream); await action.Should().ThrowAsync(); } [Fact] public async Task CompareAsync_NullToStream_ThrowsArgumentNullException() { // Arrange using var fromStream = new MemoryStream(); // Act & Assert var action = async () => await _differ.CompareAsync(fromStream, null!); await action.Should().ThrowAsync(); } [Fact] public async Task CompareAsync_DeltaHasCorrectScope() { // Arrange var fromData = CreateTestData(4096); var toData = CreateTestData(4096, seed: 99); using var fromStream = new MemoryStream(fromData); using var toStream = new MemoryStream(toData); // Act var deltas = await _differ.CompareAsync(fromStream, toStream); // Assert deltas.Should().AllSatisfy(d => d.Scope.Should().Be("byte")); } [Fact] public async Task CompareAsync_DeltasAreDeterministic() { // Arrange var fromData = CreateTestData(8192); var toData = CreateTestData(8192, seed: 99); // Act - Compare twice using var fromStream1 = new MemoryStream(fromData); using var toStream1 = new MemoryStream(toData); var deltas1 = await _differ.CompareAsync(fromStream1, toStream1); using var fromStream2 = new MemoryStream(fromData); using var toStream2 = new MemoryStream(toData); var deltas2 = await _differ.CompareAsync(fromStream2, toStream2); // Assert - Results should be identical deltas1.Should().HaveCount(deltas2.Count); for (var i = 0; i < deltas1.Count; i++) { deltas1[i].Offset.Should().Be(deltas2[i].Offset); deltas1[i].Size.Should().Be(deltas2[i].Size); deltas1[i].FromHash.Should().Be(deltas2[i].FromHash); deltas1[i].ToHash.Should().Be(deltas2[i].ToHash); } } [Fact] public async Task CompareAsync_HashesAreValidSha256Hex() { // Arrange var fromData = CreateTestData(4096); var toData = CreateTestData(4096, seed: 99); using var fromStream = new MemoryStream(fromData); using var toStream = new MemoryStream(toData); // Act var deltas = await _differ.CompareAsync(fromStream, toStream); // Assert deltas.Should().NotBeEmpty(); foreach (var delta in deltas) { if (delta.FromHash != "absent" && delta.FromHash != "truncated" && delta.FromHash != "removed") { delta.FromHash.Should().MatchRegex("^[a-f0-9]{64}$"); } if (delta.ToHash != "absent" && delta.ToHash != "truncated" && delta.ToHash != "removed") { delta.ToHash.Should().MatchRegex("^[a-f0-9]{64}$"); } } } [Fact] public async Task CompareAsync_MinConsecutiveChanges_MergesDeltas() { // Arrange var fromData = CreateTestData(8192); var toData = CreateTestData(8192, seed: 99); // Completely different using var fromStream = new MemoryStream(fromData); using var toStream = new MemoryStream(toData); var options = new ByteDiffOptions { WindowSize = 1024, StepSize = 1024, MinConsecutiveChanges = 2 }; // Act var deltas = await _differ.CompareAsync(fromStream, toStream, options); // Assert - Should have merged consecutive changes deltas.Should().NotBeEmpty(); // With 8192 bytes and 1024 window, there are 8 windows // All windows are different, so they should be merged into fewer deltas deltas.Count.Should().BeLessThan(8); } [Fact] public async Task CompareAsync_AddedBytes_DetectedAsAbsent() { // Arrange var fromData = CreateTestData(4096); var toData = new byte[8192]; Array.Copy(fromData, toData, fromData.Length); new Random(99).NextBytes(toData.AsSpan(4096)); // Add random data at end using var fromStream = new MemoryStream(fromData); using var toStream = new MemoryStream(toData); // Act var deltas = await _differ.CompareAsync(fromStream, toStream); // Assert deltas.Should().Contain(d => d.FromHash == "absent"); } // Helper methods private static byte[] CreateTestData(int size, int seed = 42) { var data = new byte[size]; new Random(seed).NextBytes(data); return data; } }