Files
git.stella-ops.org/src/Scanner/__Tests/StellaOps.Scanner.ChangeTrace.Tests/ByteDiff/ByteLevelDifferTests.cs

349 lines
11 KiB
C#

// 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;
/// <summary>
/// Tests for ByteLevelDiffer.
/// </summary>
[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<OperationCanceledException>();
}
[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<ArgumentNullException>();
}
[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<ArgumentNullException>();
}
[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;
}
}