// // Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later. // using FluentAssertions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Time.Testing; namespace StellaOps.HybridLogicalClock.Tests; /// /// Unit tests for . /// [Trait("Category", "Unit")] public sealed class HybridLogicalClockTests { private const string TestNodeId = "test-node-1"; private static readonly ILogger NullLogger = NullLogger.Instance; [Fact] public void Tick_Monotonic_SuccessiveTicksAlwaysIncrease() { // Arrange var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow); var stateStore = new InMemoryHlcStateStore(); var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger); // Act var timestamps = Enumerable.Range(0, 100) .Select(_ => clock.Tick()) .ToList(); // Assert for (var i = 1; i < timestamps.Count; i++) { timestamps[i].Should().BeGreaterThan(timestamps[i - 1], $"Timestamp {i} should be greater than timestamp {i - 1}"); } } [Fact] public void Tick_SamePhysicalTime_IncrementsCounter() { // Arrange var fixedTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); var timeProvider = new FakeTimeProvider(fixedTime); var stateStore = new InMemoryHlcStateStore(); var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger); // Act var first = clock.Tick(); var second = clock.Tick(); var third = clock.Tick(); // Assert first.LogicalCounter.Should().Be(0); second.LogicalCounter.Should().Be(1); third.LogicalCounter.Should().Be(2); // All should have same physical time first.PhysicalTime.Should().Be(second.PhysicalTime); second.PhysicalTime.Should().Be(third.PhysicalTime); } [Fact] public void Tick_NewPhysicalTime_ResetsCounter() { // Arrange var startTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); var timeProvider = new FakeTimeProvider(startTime); var stateStore = new InMemoryHlcStateStore(); var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger); // Act - generate some ticks clock.Tick(); clock.Tick(); var beforeAdvance = clock.Tick(); // Advance time timeProvider.Advance(TimeSpan.FromMilliseconds(1)); var afterAdvance = clock.Tick(); // Assert beforeAdvance.LogicalCounter.Should().Be(2); afterAdvance.LogicalCounter.Should().Be(0); afterAdvance.PhysicalTime.Should().BeGreaterThan(beforeAdvance.PhysicalTime); } [Fact] public void Tick_NodeId_IsCorrectlySet() { // Arrange var timeProvider = new FakeTimeProvider(); var stateStore = new InMemoryHlcStateStore(); var clock = new HybridLogicalClock(timeProvider, "my-custom-node", stateStore, NullLogger); // Act var timestamp = clock.Tick(); // Assert timestamp.NodeId.Should().Be("my-custom-node"); clock.NodeId.Should().Be("my-custom-node"); } [Fact] public void Receive_RemoteTimestampAhead_MergesCorrectly() { // Arrange var localTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); var timeProvider = new FakeTimeProvider(localTime); var stateStore = new InMemoryHlcStateStore(); var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger); // Local tick first var localTick = clock.Tick(); // Remote timestamp is 100ms ahead var remote = new HlcTimestamp { PhysicalTime = localTime.AddMilliseconds(100).ToUnixTimeMilliseconds(), NodeId = "remote-node", LogicalCounter = 5 }; // Act var result = clock.Receive(remote); // Assert result.PhysicalTime.Should().Be(remote.PhysicalTime); result.LogicalCounter.Should().Be(6); // remote counter + 1 result.NodeId.Should().Be(TestNodeId); } [Fact] public void Receive_LocalTimestampAhead_MergesCorrectly() { // Arrange var localTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); var timeProvider = new FakeTimeProvider(localTime); var stateStore = new InMemoryHlcStateStore(); var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger); // Generate several local ticks to advance counter clock.Tick(); clock.Tick(); var localState = clock.Tick(); // Remote timestamp is behind var remote = new HlcTimestamp { PhysicalTime = localTime.AddMilliseconds(-100).ToUnixTimeMilliseconds(), NodeId = "remote-node", LogicalCounter = 0 }; // Act var result = clock.Receive(remote); // Assert result.PhysicalTime.Should().Be(localState.PhysicalTime); result.LogicalCounter.Should().Be(localState.LogicalCounter + 1); } [Fact] public void Receive_SamePhysicalTime_MergesCounters() { // Arrange var localTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); var timeProvider = new FakeTimeProvider(localTime); var stateStore = new InMemoryHlcStateStore(); var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger); // Local tick clock.Tick(); clock.Tick(); var localState = clock.Current; // counter = 1 // Remote timestamp with same physical time but higher counter var remote = new HlcTimestamp { PhysicalTime = localTime.ToUnixTimeMilliseconds(), NodeId = "remote-node", LogicalCounter = 10 }; // Act var result = clock.Receive(remote); // Assert result.PhysicalTime.Should().Be(localTime.ToUnixTimeMilliseconds()); result.LogicalCounter.Should().Be(11); // max(local, remote) + 1 } [Fact] public void Receive_ClockSkewExceeded_ThrowsException() { // Arrange var localTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); var timeProvider = new FakeTimeProvider(localTime); var stateStore = new InMemoryHlcStateStore(); var maxSkew = TimeSpan.FromMinutes(1); var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger, maxSkew); // Remote timestamp is 2 minutes ahead (exceeds 1 minute tolerance) var remote = new HlcTimestamp { PhysicalTime = localTime.AddMinutes(2).ToUnixTimeMilliseconds(), NodeId = "remote-node", LogicalCounter = 0 }; // Act var act = () => clock.Receive(remote); // Assert act.Should().Throw() .Where(e => e.MaxAllowedSkew == maxSkew) .Where(e => e.ActualSkew > maxSkew); } [Fact] public void Current_ReturnsLatestState() { // Arrange var timeProvider = new FakeTimeProvider(); var stateStore = new InMemoryHlcStateStore(); var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger); // Act var tick1 = clock.Tick(); var current1 = clock.Current; var tick2 = clock.Tick(); var current2 = clock.Current; // Assert current1.Should().Be(tick1); current2.Should().Be(tick2); } [Fact] public void Tick_PersistsStateToStore() { // Arrange var timeProvider = new FakeTimeProvider(); var stateStore = new InMemoryHlcStateStore(); var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger); // Act clock.Tick(); // Assert - state should be persisted after tick stateStore.GetAllStates().Count.Should().Be(1); } [Fact] public void Constructor_NullTimeProvider_ThrowsArgumentNullException() { // Arrange & Act var act = () => new HybridLogicalClock(null!, TestNodeId, new InMemoryHlcStateStore(), NullLogger); // Assert act.Should().Throw() .WithParameterName("timeProvider"); } [Theory] [InlineData(null)] [InlineData("")] [InlineData(" ")] public void Constructor_InvalidNodeId_ThrowsArgumentException(string? nodeId) { // Arrange & Act var act = () => new HybridLogicalClock( new FakeTimeProvider(), nodeId!, new InMemoryHlcStateStore(), NullLogger); // Assert act.Should().Throw(); } [Fact] public void Constructor_NullStateStore_ThrowsArgumentNullException() { // Arrange & Act var act = () => new HybridLogicalClock( new FakeTimeProvider(), TestNodeId, null!, NullLogger); // Assert act.Should().Throw() .WithParameterName("stateStore"); } [Fact] public void Constructor_NullLogger_ThrowsArgumentNullException() { // Arrange & Act var act = () => new HybridLogicalClock( new FakeTimeProvider(), TestNodeId, new InMemoryHlcStateStore(), null!); // Assert act.Should().Throw() .WithParameterName("logger"); } }