save progress

This commit is contained in:
StellaOps Bot
2026-01-06 09:42:02 +02:00
parent 94d68bee8b
commit 37e11918e0
443 changed files with 85863 additions and 897 deletions

View File

@@ -0,0 +1,142 @@
// <copyright file="HlcTimestampJsonConverterTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using System.Text.Json;
using FluentAssertions;
namespace StellaOps.HybridLogicalClock.Tests;
/// <summary>
/// Unit tests for <see cref="HlcTimestampJsonConverter"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class HlcTimestampJsonConverterTests
{
private readonly JsonSerializerOptions _options = new()
{
Converters = { new HlcTimestampJsonConverter() }
};
[Fact]
public void Serialize_ProducesSortableString()
{
// Arrange
var timestamp = new HlcTimestamp
{
PhysicalTime = 1704067200000,
NodeId = "node1",
LogicalCounter = 42
};
// Act
var json = JsonSerializer.Serialize(timestamp, _options);
// Assert
json.Should().Be("\"1704067200000-node1-000042\"");
}
[Fact]
public void Deserialize_ParsesSortableString()
{
// Arrange
var json = "\"1704067200000-node1-000042\"";
// Act
var result = JsonSerializer.Deserialize<HlcTimestamp>(json, _options);
// Assert
result.PhysicalTime.Should().Be(1704067200000);
result.NodeId.Should().Be("node1");
result.LogicalCounter.Should().Be(42);
}
[Fact]
public void RoundTrip_PreservesValues()
{
// Arrange
var original = new HlcTimestamp
{
PhysicalTime = 1704067200000,
NodeId = "scheduler-east-1",
LogicalCounter = 999
};
// Act
var json = JsonSerializer.Serialize(original, _options);
var deserialized = JsonSerializer.Deserialize<HlcTimestamp>(json, _options);
// Assert
deserialized.Should().Be(original);
}
[Fact]
public void Deserialize_Null_ReturnsZero()
{
// Arrange
var json = "null";
// Act
var result = JsonSerializer.Deserialize<HlcTimestamp>(json, _options);
// Assert
result.Should().Be(HlcTimestamp.Zero);
}
[Fact]
public void Deserialize_InvalidFormat_ThrowsJsonException()
{
// Arrange
var json = "\"invalid\"";
// Act
var act = () => JsonSerializer.Deserialize<HlcTimestamp>(json, _options);
// Assert
act.Should().Throw<JsonException>();
}
[Fact]
public void Deserialize_WrongTokenType_ThrowsJsonException()
{
// Arrange
var json = "12345"; // number, not string
// Act
var act = () => JsonSerializer.Deserialize<HlcTimestamp>(json, _options);
// Assert
act.Should().Throw<JsonException>();
}
[Fact]
public void SerializeInObject_WorksCorrectly()
{
// Arrange
var obj = new TestWrapper
{
Timestamp = new HlcTimestamp
{
PhysicalTime = 1704067200000,
NodeId = "node1",
LogicalCounter = 1
},
Name = "Test"
};
// Act
var json = JsonSerializer.Serialize(obj, _options);
var deserialized = JsonSerializer.Deserialize<TestWrapper>(json, _options);
// Assert
deserialized.Should().NotBeNull();
deserialized!.Timestamp.Should().Be(obj.Timestamp);
deserialized.Name.Should().Be(obj.Name);
}
private sealed class TestWrapper
{
public HlcTimestamp Timestamp { get; set; }
public string? Name { get; set; }
}
}

View File

@@ -0,0 +1,366 @@
// <copyright file="HlcTimestampTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
namespace StellaOps.HybridLogicalClock.Tests;
/// <summary>
/// Unit tests for <see cref="HlcTimestamp"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class HlcTimestampTests
{
[Fact]
public void ToSortableString_FormatsCorrectly()
{
// Arrange
var timestamp = new HlcTimestamp
{
PhysicalTime = 1704067200000, // 2024-01-01 00:00:00 UTC
NodeId = "scheduler-east-1",
LogicalCounter = 42
};
// Act
var result = timestamp.ToSortableString();
// Assert
result.Should().Be("1704067200000-scheduler-east-1-000042");
}
[Fact]
public void Parse_RoundTrip_PreservesValues()
{
// Arrange
var original = new HlcTimestamp
{
PhysicalTime = 1704067200000,
NodeId = "scheduler-east-1",
LogicalCounter = 42
};
// Act
var serialized = original.ToSortableString();
var parsed = HlcTimestamp.Parse(serialized);
// Assert
parsed.Should().Be(original);
parsed.PhysicalTime.Should().Be(original.PhysicalTime);
parsed.NodeId.Should().Be(original.NodeId);
parsed.LogicalCounter.Should().Be(original.LogicalCounter);
}
[Fact]
public void Parse_WithHyphensInNodeId_ParsesCorrectly()
{
// Arrange - NodeId contains multiple hyphens
var original = new HlcTimestamp
{
PhysicalTime = 1704067200000,
NodeId = "scheduler-east-1-prod",
LogicalCounter = 123
};
// Act
var serialized = original.ToSortableString();
var parsed = HlcTimestamp.Parse(serialized);
// Assert
parsed.NodeId.Should().Be("scheduler-east-1-prod");
}
[Fact]
public void TryParse_ValidString_ReturnsTrue()
{
// Act
var result = HlcTimestamp.TryParse("1704067200000-node1-000001", out var timestamp);
// Assert
result.Should().BeTrue();
timestamp.PhysicalTime.Should().Be(1704067200000);
timestamp.NodeId.Should().Be("node1");
timestamp.LogicalCounter.Should().Be(1);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData("invalid")]
[InlineData("abc-node-001")]
[InlineData("1234567890123--000001")]
[InlineData("1234567890123-node-abc")]
public void TryParse_InvalidString_ReturnsFalse(string? input)
{
// Act
var result = HlcTimestamp.TryParse(input, out _);
// Assert
result.Should().BeFalse();
}
[Fact]
public void Parse_InvalidString_ThrowsFormatException()
{
// Act
var act = () => HlcTimestamp.Parse("invalid");
// Assert
act.Should().Throw<FormatException>();
}
[Fact]
public void Parse_Null_ThrowsArgumentNullException()
{
// Act
var act = () => HlcTimestamp.Parse(null!);
// Assert
act.Should().Throw<ArgumentNullException>();
}
[Fact]
public void CompareTo_SamePhysicalTime_HigherCounterIsGreater()
{
// Arrange
var earlier = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node1",
LogicalCounter = 1
};
var later = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node1",
LogicalCounter = 2
};
// Act & Assert
earlier.CompareTo(later).Should().BeLessThan(0);
later.CompareTo(earlier).Should().BeGreaterThan(0);
(earlier < later).Should().BeTrue();
(later > earlier).Should().BeTrue();
}
[Fact]
public void CompareTo_DifferentPhysicalTime_HigherTimeIsGreater()
{
// Arrange
var earlier = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node1",
LogicalCounter = 999
};
var later = new HlcTimestamp
{
PhysicalTime = 1001,
NodeId = "node1",
LogicalCounter = 0
};
// Act & Assert
earlier.CompareTo(later).Should().BeLessThan(0);
later.CompareTo(earlier).Should().BeGreaterThan(0);
}
[Fact]
public void CompareTo_SameTimeAndCounter_NodeIdBreaksTie()
{
// Arrange
var a = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "aaa",
LogicalCounter = 1
};
var b = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "bbb",
LogicalCounter = 1
};
// Act & Assert
a.CompareTo(b).Should().BeLessThan(0);
b.CompareTo(a).Should().BeGreaterThan(0);
}
[Fact]
public void CompareTo_Equal_ReturnsZero()
{
// Arrange
var a = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node1",
LogicalCounter = 1
};
var b = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node1",
LogicalCounter = 1
};
// Act & Assert
a.CompareTo(b).Should().Be(0);
(a <= b).Should().BeTrue();
(a >= b).Should().BeTrue();
}
[Fact]
public void Zero_HasExpectedValues()
{
// Act
var zero = HlcTimestamp.Zero;
// Assert
zero.PhysicalTime.Should().Be(0);
zero.NodeId.Should().BeEmpty();
zero.LogicalCounter.Should().Be(0);
}
[Fact]
public void PhysicalDateTime_ConvertsCorrectly()
{
// Arrange
var timestamp = new HlcTimestamp
{
PhysicalTime = 1704067200000, // 2024-01-01 00:00:00 UTC
NodeId = "node1",
LogicalCounter = 0
};
// Act
var dateTime = timestamp.PhysicalDateTime;
// Assert
dateTime.Should().Be(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
}
[Fact]
public void Equality_SameValues_AreEqual()
{
// Arrange
var a = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node1",
LogicalCounter = 1
};
var b = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node1",
LogicalCounter = 1
};
// Assert
a.Should().Be(b);
(a == b).Should().BeTrue();
a.GetHashCode().Should().Be(b.GetHashCode());
}
[Fact]
public void Equality_DifferentValues_AreNotEqual()
{
// Arrange
var a = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node1",
LogicalCounter = 1
};
var b = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node1",
LogicalCounter = 2
};
// Assert
a.Should().NotBe(b);
(a != b).Should().BeTrue();
}
[Fact]
public void ToString_ReturnsSortableString()
{
// Arrange
var timestamp = new HlcTimestamp
{
PhysicalTime = 1704067200000,
NodeId = "node1",
LogicalCounter = 42
};
// Act
var result = timestamp.ToString();
// Assert
result.Should().Be(timestamp.ToSortableString());
}
[Fact]
public void CompareTo_ObjectOverload_WorksCorrectly()
{
// Arrange
var a = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node1",
LogicalCounter = 1
};
object b = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node1",
LogicalCounter = 2
};
// Act
var result = a.CompareTo(b);
// Assert
result.Should().BeLessThan(0);
}
[Fact]
public void CompareTo_Null_ReturnsPositive()
{
// Arrange
var timestamp = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node1",
LogicalCounter = 1
};
// Act
var result = timestamp.CompareTo(null);
// Assert
result.Should().BeGreaterThan(0);
}
[Fact]
public void CompareTo_WrongType_ThrowsArgumentException()
{
// Arrange
var timestamp = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node1",
LogicalCounter = 1
};
// Act
var act = () => timestamp.CompareTo("not a timestamp");
// Assert
act.Should().Throw<ArgumentException>();
}
}

View File

@@ -0,0 +1,376 @@
// <copyright file="HybridLogicalClockTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using Microsoft.Extensions.Time.Testing;
namespace StellaOps.HybridLogicalClock.Tests;
/// <summary>
/// Unit tests for <see cref="HybridLogicalClock"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class HybridLogicalClockTests
{
private const string TestNodeId = "test-node-1";
[Fact]
public void Tick_Monotonic_SuccessiveTicksAlwaysIncrease()
{
// Arrange
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
var stateStore = new InMemoryHlcStateStore();
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore);
// 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);
// 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);
// 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);
// 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);
// 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);
// 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);
// 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, 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<HlcClockSkewException>()
.Where(e => e.MaxAllowedSkew == maxSkew)
.Where(e => e.ObservedSkew > maxSkew);
}
[Fact]
public void Current_ReturnsLatestState()
{
// Arrange
var timeProvider = new FakeTimeProvider();
var stateStore = new InMemoryHlcStateStore();
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore);
// 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 async Task InitializeAsync_NoPersistedState_StartsFromCurrentTime()
{
// Arrange
var ct = TestContext.Current.CancellationToken;
var startTime = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(startTime);
var stateStore = new InMemoryHlcStateStore();
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore);
// Act
var recovered = await clock.InitializeAsync(ct);
// Assert
recovered.Should().BeFalse();
clock.Current.PhysicalTime.Should().Be(startTime.ToUnixTimeMilliseconds());
clock.Current.LogicalCounter.Should().Be(0);
}
[Fact]
public async Task InitializeAsync_WithPersistedState_ResumesFromPersisted()
{
// Arrange
var ct = TestContext.Current.CancellationToken;
var startTime = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(startTime);
var stateStore = new InMemoryHlcStateStore();
// Pre-persist state
var persistedState = new HlcTimestamp
{
PhysicalTime = startTime.ToUnixTimeMilliseconds(),
NodeId = TestNodeId,
LogicalCounter = 50
};
await stateStore.SaveAsync(persistedState, ct);
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore);
// Act
var recovered = await clock.InitializeAsync(ct);
var firstTick = clock.Tick();
// Assert
recovered.Should().BeTrue();
firstTick.LogicalCounter.Should().BeGreaterThan(50); // Should continue from persisted + 1
}
[Fact]
public async Task InitializeAsync_PersistedStateOlderThanCurrent_UsesCurrentTime()
{
// Arrange
var ct = TestContext.Current.CancellationToken;
var startTime = new DateTimeOffset(2024, 1, 1, 12, 0, 0, TimeSpan.Zero);
var timeProvider = new FakeTimeProvider(startTime);
var stateStore = new InMemoryHlcStateStore();
// Pre-persist OLD state
var persistedState = new HlcTimestamp
{
PhysicalTime = startTime.AddHours(-1).ToUnixTimeMilliseconds(),
NodeId = TestNodeId,
LogicalCounter = 1000
};
await stateStore.SaveAsync(persistedState, ct);
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore);
// Act
await clock.InitializeAsync(ct);
var firstTick = clock.Tick();
// Assert
// Should use current physical time since it's greater
firstTick.PhysicalTime.Should().Be(startTime.ToUnixTimeMilliseconds());
firstTick.LogicalCounter.Should().Be(1); // Reset because physical time advanced
}
[Fact]
public async Task Tick_PersistsState()
{
// Arrange
var ct = TestContext.Current.CancellationToken;
var timeProvider = new FakeTimeProvider();
var stateStore = new InMemoryHlcStateStore();
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore);
// Act
var tick = clock.Tick();
// Wait a bit for fire-and-forget persistence
await Task.Delay(50, ct);
// Assert
stateStore.Count.Should().Be(1);
}
[Fact]
public void Constructor_NullTimeProvider_ThrowsArgumentNullException()
{
// Arrange & Act
var act = () => new HybridLogicalClock(null!, TestNodeId, new InMemoryHlcStateStore());
// Assert
act.Should().Throw<ArgumentNullException>()
.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());
// Assert
act.Should().Throw<ArgumentException>();
}
[Fact]
public void Constructor_NullStateStore_ThrowsArgumentNullException()
{
// Arrange & Act
var act = () => new HybridLogicalClock(
new FakeTimeProvider(),
TestNodeId,
null!);
// Assert
act.Should().Throw<ArgumentNullException>()
.WithParameterName("stateStore");
}
}

View File

@@ -0,0 +1,168 @@
// <copyright file="InMemoryHlcStateStoreTests.cs" company="StellaOps">
// Copyright (c) StellaOps. Licensed under AGPL-3.0-or-later.
// </copyright>
using FluentAssertions;
using Xunit;
namespace StellaOps.HybridLogicalClock.Tests;
/// <summary>
/// Unit tests for <see cref="InMemoryHlcStateStore"/>.
/// </summary>
[Trait("Category", "Unit")]
public sealed class InMemoryHlcStateStoreTests
{
[Fact]
public async Task LoadAsync_NoState_ReturnsNull()
{
// Arrange
var store = new InMemoryHlcStateStore();
var ct = TestContext.Current.CancellationToken;
// Act
var result = await store.LoadAsync("node1", ct);
// Assert
result.Should().BeNull();
}
[Fact]
public async Task SaveAsync_ThenLoadAsync_ReturnsState()
{
// Arrange
var store = new InMemoryHlcStateStore();
var ct = TestContext.Current.CancellationToken;
var timestamp = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node1",
LogicalCounter = 5
};
// Act
await store.SaveAsync(timestamp, ct);
var result = await store.LoadAsync("node1", ct);
// Assert
result.Should().Be(timestamp);
}
[Fact]
public async Task SaveAsync_GreaterTimestamp_Updates()
{
// Arrange
var store = new InMemoryHlcStateStore();
var ct = TestContext.Current.CancellationToken;
var first = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node1",
LogicalCounter = 5
};
var second = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node1",
LogicalCounter = 10
};
// Act
await store.SaveAsync(first, ct);
await store.SaveAsync(second, ct);
var result = await store.LoadAsync("node1", ct);
// Assert
result.Should().Be(second);
}
[Fact]
public async Task SaveAsync_SmallerTimestamp_DoesNotUpdate()
{
// Arrange
var store = new InMemoryHlcStateStore();
var ct = TestContext.Current.CancellationToken;
var first = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node1",
LogicalCounter = 10
};
var second = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node1",
LogicalCounter = 5
};
// Act
await store.SaveAsync(first, ct);
await store.SaveAsync(second, ct);
var result = await store.LoadAsync("node1", ct);
// Assert
result.Should().Be(first);
}
[Fact]
public async Task SaveAsync_MultipleNodes_Isolated()
{
// Arrange
var store = new InMemoryHlcStateStore();
var ct = TestContext.Current.CancellationToken;
var node1State = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node1",
LogicalCounter = 1
};
var node2State = new HlcTimestamp
{
PhysicalTime = 2000,
NodeId = "node2",
LogicalCounter = 2
};
// Act
await store.SaveAsync(node1State, ct);
await store.SaveAsync(node2State, ct);
// Assert
var loaded1 = await store.LoadAsync("node1", ct);
var loaded2 = await store.LoadAsync("node2", ct);
loaded1.Should().Be(node1State);
loaded2.Should().Be(node2State);
store.Count.Should().Be(2);
}
[Fact]
public async Task Clear_RemovesAllState()
{
// Arrange
var store = new InMemoryHlcStateStore();
var ct = TestContext.Current.CancellationToken;
await store.SaveAsync(new HlcTimestamp { PhysicalTime = 1, NodeId = "n1", LogicalCounter = 0 }, ct);
await store.SaveAsync(new HlcTimestamp { PhysicalTime = 2, NodeId = "n2", LogicalCounter = 0 }, ct);
// Act
store.Clear();
// Assert
store.Count.Should().Be(0);
}
[Fact]
public async Task LoadAsync_NullNodeId_ThrowsArgumentNullException()
{
// Arrange
var store = new InMemoryHlcStateStore();
var ct = TestContext.Current.CancellationToken;
// Act
var act = () => store.LoadAsync(null!, ct);
// Assert
await act.Should().ThrowAsync<ArgumentNullException>();
}
}

View File

@@ -0,0 +1,29 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.TimeProvider.Testing" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\StellaOps.HybridLogicalClock\StellaOps.HybridLogicalClock.csproj" />
</ItemGroup>
</Project>