// -----------------------------------------------------------------------------
// 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);
}
}