// ----------------------------------------------------------------------------- // HybridLogicalClockTests.cs // Sprint: SPRINT_20260105_002_001_LB_hlc_core_library // Task: HLC-008 - Write unit tests for HLC // ----------------------------------------------------------------------------- // Disable xUnit analyzer warning for async methods - cancellation token not relevant for these unit tests #pragma warning disable xUnit1051 using Microsoft.Extensions.Logging.Abstractions; using StellaOps.TestKit; using Xunit; namespace StellaOps.HybridLogicalClock.Tests; /// /// Unit tests for HybridLogicalClock class. /// [Trait("Category", TestCategories.Unit)] public class HybridLogicalClockTests { private const string TestNodeId = "test-node"; #region Tick Monotonicity Tests [Fact] public void Tick_Monotonic_SuccessiveTicks_AlwaysIncrease() { var timeProvider = new FakeTimeProvider(); var stateStore = new InMemoryHlcStateStore(); var clock = CreateClock(timeProvider, stateStore); var timestamps = new List(); // Generate multiple ticks at the same physical time for (var i = 0; i < 100; i++) { timestamps.Add(clock.Tick()); } // Verify each subsequent timestamp is greater for (var i = 1; i < timestamps.Count; i++) { Assert.True(timestamps[i] > timestamps[i - 1], $"Timestamp {i} should be greater than timestamp {i - 1}"); } } [Fact] public void Tick_Monotonic_EvenWithBackwardPhysicalTime() { var timeProvider = new FakeTimeProvider(); var stateStore = new InMemoryHlcStateStore(); var clock = CreateClock(timeProvider, stateStore); // Get first timestamp var ts1 = clock.Tick(); // Move time backward (simulating clock adjustment) timeProvider.Advance(TimeSpan.FromSeconds(-5)); // Get second timestamp - should still be greater var ts2 = clock.Tick(); Assert.True(ts2 > ts1, "HLC should maintain monotonicity even with backward physical time"); } [Fact] public void Tick_SamePhysicalTime_IncrementCounter() { var timeProvider = new FakeTimeProvider(); var stateStore = new InMemoryHlcStateStore(); var clock = CreateClock(timeProvider, stateStore); var ts1 = clock.Tick(); var ts2 = clock.Tick(); Assert.Equal(ts1.PhysicalTime, ts2.PhysicalTime); Assert.Equal(ts1.LogicalCounter + 1, ts2.LogicalCounter); } [Fact] public void Tick_NewPhysicalTime_ResetCounter() { var timeProvider = new FakeTimeProvider(); var stateStore = new InMemoryHlcStateStore(); var clock = CreateClock(timeProvider, stateStore); // First tick var ts1 = clock.Tick(); clock.Tick(); // Counter = 1 clock.Tick(); // Counter = 2 // Advance physical time timeProvider.Advance(TimeSpan.FromMilliseconds(1)); // Next tick should reset counter var ts2 = clock.Tick(); Assert.True(ts2.PhysicalTime > ts1.PhysicalTime); Assert.Equal(0, ts2.LogicalCounter); } [Fact] public void Tick_HighFrequency_AllUnique() { var timeProvider = new FakeTimeProvider(); var stateStore = new InMemoryHlcStateStore(); var clock = CreateClock(timeProvider, stateStore); var timestamps = new HashSet(); for (var i = 0; i < 10000; i++) { var ts = clock.Tick(); var str = ts.ToSortableString(); Assert.True(timestamps.Add(str), $"Duplicate timestamp detected: {str}"); } Assert.Equal(10000, timestamps.Count); } #endregion #region Receive Tests [Fact] public void Receive_MergesCorrectly_WhenRemoteIsAhead() { var timeProvider = new FakeTimeProvider(); timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)); var stateStore = new InMemoryHlcStateStore(); var clock = CreateClock(timeProvider, stateStore); // Create a remote timestamp in the future (but within skew threshold) var remote = new HlcTimestamp { PhysicalTime = timeProvider.GetUtcNow().ToUnixTimeMilliseconds() + 10000, // 10 seconds ahead NodeId = "remote-node", LogicalCounter = 5 }; var result = clock.Receive(remote); // Result should be at remote's physical time with incremented counter Assert.Equal(remote.PhysicalTime, result.PhysicalTime); Assert.Equal(remote.LogicalCounter + 1, result.LogicalCounter); Assert.Equal(TestNodeId, result.NodeId); } [Fact] public void Receive_MergesCorrectly_WhenLocalIsAhead() { var timeProvider = new FakeTimeProvider(); timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)); var stateStore = new InMemoryHlcStateStore(); var clock = CreateClock(timeProvider, stateStore); // Tick to advance local clock var localTs = clock.Tick(); // Create a remote timestamp in the past var remote = new HlcTimestamp { PhysicalTime = localTs.PhysicalTime - 5000, // 5 seconds behind NodeId = "remote-node", LogicalCounter = 10 }; var result = clock.Receive(remote); // Result should maintain local physical time and increment local counter Assert.Equal(localTs.PhysicalTime, result.PhysicalTime); Assert.True(result.LogicalCounter > localTs.LogicalCounter); } [Fact] public void Receive_MergesCorrectly_WhenTimesEqual() { var timeProvider = new FakeTimeProvider(); timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)); var stateStore = new InMemoryHlcStateStore(); var clock = CreateClock(timeProvider, stateStore); // Tick to establish local state var localTs = clock.Tick(); // Create remote with same physical time but higher counter var remote = new HlcTimestamp { PhysicalTime = localTs.PhysicalTime, NodeId = "remote-node", LogicalCounter = 100 }; var result = clock.Receive(remote); // Result should have same physical time, counter = max(local, remote) + 1 Assert.Equal(localTs.PhysicalTime, result.PhysicalTime); Assert.Equal(101, result.LogicalCounter); // max(1, 100) + 1 } [Fact] public void Receive_AfterReceive_MaintainsMonotonicity() { var timeProvider = new FakeTimeProvider(); var stateStore = new InMemoryHlcStateStore(); var clock = CreateClock(timeProvider, stateStore); var ts1 = clock.Tick(); var remote = new HlcTimestamp { PhysicalTime = ts1.PhysicalTime + 1000, NodeId = "remote", LogicalCounter = 5 }; var ts2 = clock.Receive(remote); var ts3 = clock.Tick(); Assert.True(ts2 > ts1); Assert.True(ts3 > ts2); } #endregion #region Clock Skew Tests [Fact] public void Receive_ClockSkewExceeded_ThrowsHlcClockSkewException() { var timeProvider = new FakeTimeProvider(); timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)); var maxSkew = TimeSpan.FromSeconds(30); var stateStore = new InMemoryHlcStateStore(); var clock = CreateClock(timeProvider, stateStore, maxSkew); // Create remote timestamp with excessive skew var remote = new HlcTimestamp { PhysicalTime = timeProvider.GetUtcNow().ToUnixTimeMilliseconds() + 60_000, // 60 seconds ahead NodeId = "remote-node", LogicalCounter = 0 }; var exception = Assert.Throws(() => clock.Receive(remote)); Assert.True(exception.ActualSkew > maxSkew); Assert.Equal(maxSkew, exception.MaxAllowedSkew); } [Fact] public void Receive_WithinSkewThreshold_Succeeds() { var timeProvider = new FakeTimeProvider(); timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)); var maxSkew = TimeSpan.FromMinutes(1); var stateStore = new InMemoryHlcStateStore(); var clock = CreateClock(timeProvider, stateStore, maxSkew); // Create remote timestamp just within threshold var remote = new HlcTimestamp { PhysicalTime = timeProvider.GetUtcNow().ToUnixTimeMilliseconds() + 55_000, // 55 seconds NodeId = "remote-node", LogicalCounter = 0 }; var result = clock.Receive(remote); Assert.Equal(TestNodeId, result.NodeId); } [Fact] public void Receive_NegativeSkew_StillChecked() { var timeProvider = new FakeTimeProvider(); timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)); var maxSkew = TimeSpan.FromSeconds(30); var stateStore = new InMemoryHlcStateStore(); var clock = CreateClock(timeProvider, stateStore, maxSkew); // Create remote timestamp far in the past var remote = new HlcTimestamp { PhysicalTime = timeProvider.GetUtcNow().ToUnixTimeMilliseconds() - 60_000, // 60 seconds behind NodeId = "remote-node", LogicalCounter = 0 }; Assert.Throws(() => clock.Receive(remote)); } #endregion #region Current Property Tests [Fact] public void Current_ReturnsCurrentState() { var timeProvider = new FakeTimeProvider(); var stateStore = new InMemoryHlcStateStore(); var clock = CreateClock(timeProvider, stateStore); var ts = clock.Tick(); var current = clock.Current; Assert.Equal(ts.PhysicalTime, current.PhysicalTime); Assert.Equal(ts.LogicalCounter, current.LogicalCounter); Assert.Equal(ts.NodeId, current.NodeId); } [Fact] public void NodeId_ReturnsConfiguredNodeId() { var timeProvider = new FakeTimeProvider(); var stateStore = new InMemoryHlcStateStore(); var clock = CreateClock(timeProvider, stateStore); Assert.Equal(TestNodeId, clock.NodeId); } #endregion #region State Initialization Tests [Fact] public async Task InitializeFromStateAsync_WithNoPersistedState_ReturnsFalse() { var timeProvider = new FakeTimeProvider(); var stateStore = new InMemoryHlcStateStore(); var clock = CreateClock(timeProvider, stateStore); var result = await clock.InitializeFromStateAsync(); Assert.False(result); } [Fact] public async Task InitializeFromStateAsync_WithPersistedState_ReturnsTrue() { var timeProvider = new FakeTimeProvider(); var stateStore = new InMemoryHlcStateStore(); // Pre-persist some state var persistedState = new HlcTimestamp { PhysicalTime = 1000, NodeId = TestNodeId, LogicalCounter = 50 }; await stateStore.SaveAsync(persistedState); var clock = CreateClock(timeProvider, stateStore); var result = await clock.InitializeFromStateAsync(); Assert.True(result); // Next tick should be greater than persisted state var ts = clock.Tick(); Assert.True(ts > persistedState); } [Fact] public async Task InitializeFromStateAsync_ResumesFromPersistedState() { var timeProvider = new FakeTimeProvider(); timeProvider.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero)); var stateStore = new InMemoryHlcStateStore(); // Pre-persist state at current physical time var persistedState = new HlcTimestamp { PhysicalTime = timeProvider.GetUtcNow().ToUnixTimeMilliseconds(), NodeId = TestNodeId, LogicalCounter = 50 }; await stateStore.SaveAsync(persistedState); var clock = CreateClock(timeProvider, stateStore); await clock.InitializeFromStateAsync(); // Since physical time matches, counter should be incremented var current = clock.Current; Assert.Equal(persistedState.PhysicalTime, current.PhysicalTime); Assert.True(current.LogicalCounter > persistedState.LogicalCounter); } #endregion #region State Persistence Tests [Fact] public async Task Tick_PersistsState() { var timeProvider = new FakeTimeProvider(); var stateStore = new InMemoryHlcStateStore(); var clock = CreateClock(timeProvider, stateStore); var ts = clock.Tick(); // Give async persistence time to complete await Task.Delay(10); var persisted = await stateStore.LoadAsync(TestNodeId); Assert.NotNull(persisted); Assert.Equal(ts.PhysicalTime, persisted.Value.PhysicalTime); } #endregion #region Constructor Validation Tests [Fact] public void Constructor_NullTimeProvider_ThrowsArgumentNullException() { Assert.Throws(() => new HybridLogicalClock( null!, TestNodeId, new InMemoryHlcStateStore(), NullLogger.Instance)); } [Fact] public void Constructor_NullNodeId_ThrowsArgumentNullException() { Assert.Throws(() => new HybridLogicalClock( TimeProvider.System, null!, new InMemoryHlcStateStore(), NullLogger.Instance)); } [Fact] public void Constructor_EmptyNodeId_ThrowsArgumentException() { Assert.Throws(() => new HybridLogicalClock( TimeProvider.System, "", new InMemoryHlcStateStore(), NullLogger.Instance)); } [Fact] public void Constructor_NullStateStore_ThrowsArgumentNullException() { Assert.Throws(() => new HybridLogicalClock( TimeProvider.System, TestNodeId, null!, NullLogger.Instance)); } [Fact] public void Constructor_NullLogger_ThrowsArgumentNullException() { Assert.Throws(() => new HybridLogicalClock( TimeProvider.System, TestNodeId, new InMemoryHlcStateStore(), null!)); } #endregion #region Causal Ordering Tests [Fact] public void Tick_Receive_Tick_MaintainsCausalOrder() { // Simulates message exchange between two nodes var timeProvider1 = new FakeTimeProvider(); var timeProvider2 = new FakeTimeProvider(); var startTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero); timeProvider1.SetUtcNow(startTime); timeProvider2.SetUtcNow(startTime); var clock1 = CreateClock(timeProvider1, new InMemoryHlcStateStore(), nodeId: "node-1"); var clock2 = CreateClock(timeProvider2, new InMemoryHlcStateStore(), nodeId: "node-2"); // Node 1 sends event var send1 = clock1.Tick(); // Node 2 receives event var recv2 = clock2.Receive(send1); // Node 2 sends reply var send2 = clock2.Tick(); // Node 1 receives reply var recv1 = clock1.Receive(send2); // Causal order: send1 < recv2 < send2 < recv1 Assert.True(send1 < recv2); Assert.True(recv2 < send2); Assert.True(send2 < recv1); } #endregion #region Helper Methods private static HybridLogicalClock CreateClock( TimeProvider timeProvider, IHlcStateStore stateStore, TimeSpan? maxSkew = null, string nodeId = TestNodeId) { return new HybridLogicalClock( timeProvider, nodeId, stateStore, NullLogger.Instance, maxSkew); } #endregion /// /// Fake TimeProvider for deterministic testing. /// private sealed class FakeTimeProvider : TimeProvider { private DateTimeOffset _now = DateTimeOffset.UtcNow; public override DateTimeOffset GetUtcNow() => _now; public void SetUtcNow(DateTimeOffset value) => _now = value; public void Advance(TimeSpan duration) => _now = _now.Add(duration); } }