docs consolidation and others

This commit is contained in:
master
2026-01-06 19:02:21 +02:00
parent d7bdca6d97
commit 4789027317
849 changed files with 16551 additions and 66770 deletions

View File

@@ -0,0 +1,452 @@
// -----------------------------------------------------------------------------
// HlcTimestampJsonConverterTests.cs
// Sprint: SPRINT_20260105_002_001_LB_hlc_core_library
// Task: HLC-008 - Write unit tests for HLC
// -----------------------------------------------------------------------------
using System.Text.Json;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.HybridLogicalClock.Tests;
/// <summary>
/// Unit tests for HlcTimestamp JSON converters.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public class HlcTimestampJsonConverterTests
{
private const long BasePhysicalTime = 1704067200000L;
#region HlcTimestampJsonConverter Tests
[Fact]
public void StringConverter_Serialize_WritesAsString()
{
var options = new JsonSerializerOptions
{
Converters = { new HlcTimestampJsonConverter() }
};
var timestamp = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime,
NodeId = "test-node",
LogicalCounter = 42
};
var json = JsonSerializer.Serialize(timestamp, options);
Assert.Equal("\"1704067200000-test-node-000042\"", json);
}
[Fact]
public void StringConverter_Deserialize_ReadsFromString()
{
var options = new JsonSerializerOptions
{
Converters = { new HlcTimestampJsonConverter() }
};
var json = "\"1704067200000-test-node-000042\"";
var result = JsonSerializer.Deserialize<HlcTimestamp>(json, options);
Assert.Equal(BasePhysicalTime, result.PhysicalTime);
Assert.Equal("test-node", result.NodeId);
Assert.Equal(42, result.LogicalCounter);
}
[Fact]
public void StringConverter_RoundTrip_PreservesValues()
{
var options = new JsonSerializerOptions
{
Converters = { new HlcTimestampJsonConverter() }
};
var original = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime,
NodeId = "scheduler-east-1",
LogicalCounter = 999999
};
var json = JsonSerializer.Serialize(original, options);
var deserialized = JsonSerializer.Deserialize<HlcTimestamp>(json, options);
Assert.Equal(original.PhysicalTime, deserialized.PhysicalTime);
Assert.Equal(original.NodeId, deserialized.NodeId);
Assert.Equal(original.LogicalCounter, deserialized.LogicalCounter);
}
[Fact]
public void StringConverter_Deserialize_NullToken_ThrowsJsonException()
{
var options = new JsonSerializerOptions
{
Converters = { new HlcTimestampJsonConverter() }
};
Assert.Throws<JsonException>(() =>
JsonSerializer.Deserialize<HlcTimestamp>("null", options));
}
[Fact]
public void StringConverter_Deserialize_NumberToken_ThrowsJsonException()
{
var options = new JsonSerializerOptions
{
Converters = { new HlcTimestampJsonConverter() }
};
Assert.Throws<JsonException>(() =>
JsonSerializer.Deserialize<HlcTimestamp>("12345", options));
}
[Fact]
public void StringConverter_Deserialize_EmptyString_ThrowsJsonException()
{
var options = new JsonSerializerOptions
{
Converters = { new HlcTimestampJsonConverter() }
};
Assert.Throws<JsonException>(() =>
JsonSerializer.Deserialize<HlcTimestamp>("\"\"", options));
}
[Fact]
public void StringConverter_Deserialize_InvalidFormat_ThrowsJsonException()
{
var options = new JsonSerializerOptions
{
Converters = { new HlcTimestampJsonConverter() }
};
var ex = Assert.Throws<JsonException>(() =>
JsonSerializer.Deserialize<HlcTimestamp>("\"invalid-format\"", options));
Assert.Contains("Invalid HlcTimestamp format", ex.Message);
}
#endregion
#region NullableHlcTimestampJsonConverter Tests
[Fact]
public void NullableConverter_Serialize_NullValue_WritesNull()
{
var options = new JsonSerializerOptions
{
Converters = { new NullableHlcTimestampJsonConverter() }
};
HlcTimestamp? value = null;
var json = JsonSerializer.Serialize(value, options);
Assert.Equal("null", json);
}
[Fact]
public void NullableConverter_Serialize_NonNullValue_WritesString()
{
var options = new JsonSerializerOptions
{
Converters = { new NullableHlcTimestampJsonConverter() }
};
HlcTimestamp? value = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime,
NodeId = "test-node",
LogicalCounter = 42
};
var json = JsonSerializer.Serialize(value, options);
Assert.Equal("\"1704067200000-test-node-000042\"", json);
}
[Fact]
public void NullableConverter_Deserialize_NullToken_ReturnsNull()
{
var options = new JsonSerializerOptions
{
Converters = { new NullableHlcTimestampJsonConverter() }
};
var result = JsonSerializer.Deserialize<HlcTimestamp?>("null", options);
Assert.Null(result);
}
[Fact]
public void NullableConverter_Deserialize_ValidString_ReturnsValue()
{
var options = new JsonSerializerOptions
{
Converters = { new NullableHlcTimestampJsonConverter() }
};
var result = JsonSerializer.Deserialize<HlcTimestamp?>(
"\"1704067200000-test-node-000042\"", options);
Assert.NotNull(result);
Assert.Equal(BasePhysicalTime, result.Value.PhysicalTime);
Assert.Equal("test-node", result.Value.NodeId);
Assert.Equal(42, result.Value.LogicalCounter);
}
[Fact]
public void NullableConverter_RoundTrip_NullValue()
{
var options = new JsonSerializerOptions
{
Converters = { new NullableHlcTimestampJsonConverter() }
};
HlcTimestamp? original = null;
var json = JsonSerializer.Serialize(original, options);
var result = JsonSerializer.Deserialize<HlcTimestamp?>(json, options);
Assert.Null(result);
}
[Fact]
public void NullableConverter_RoundTrip_NonNullValue()
{
var options = new JsonSerializerOptions
{
Converters = { new NullableHlcTimestampJsonConverter() }
};
HlcTimestamp? original = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime,
NodeId = "node",
LogicalCounter = 100
};
var json = JsonSerializer.Serialize(original, options);
var result = JsonSerializer.Deserialize<HlcTimestamp?>(json, options);
Assert.NotNull(result);
Assert.Equal(original.Value.PhysicalTime, result.Value.PhysicalTime);
}
#endregion
#region HlcTimestampObjectJsonConverter Tests
[Fact]
public void ObjectConverter_Serialize_WritesObject()
{
var options = new JsonSerializerOptions
{
Converters = { new HlcTimestampObjectJsonConverter() }
};
var timestamp = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime,
NodeId = "test-node",
LogicalCounter = 42
};
var json = JsonSerializer.Serialize(timestamp, options);
var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.Equal(BasePhysicalTime, root.GetProperty("physicalTime").GetInt64());
Assert.Equal("test-node", root.GetProperty("nodeId").GetString());
Assert.Equal(42, root.GetProperty("logicalCounter").GetInt32());
}
[Fact]
public void ObjectConverter_Deserialize_ReadsObject()
{
var options = new JsonSerializerOptions
{
Converters = { new HlcTimestampObjectJsonConverter() }
};
var json = """{"physicalTime":1704067200000,"nodeId":"test-node","logicalCounter":42}""";
var result = JsonSerializer.Deserialize<HlcTimestamp>(json, options);
Assert.Equal(BasePhysicalTime, result.PhysicalTime);
Assert.Equal("test-node", result.NodeId);
Assert.Equal(42, result.LogicalCounter);
}
[Fact]
public void ObjectConverter_Deserialize_PascalCaseProperties_Works()
{
var options = new JsonSerializerOptions
{
Converters = { new HlcTimestampObjectJsonConverter() }
};
var json = """{"PhysicalTime":1704067200000,"NodeId":"test-node","LogicalCounter":42}""";
var result = JsonSerializer.Deserialize<HlcTimestamp>(json, options);
Assert.Equal(BasePhysicalTime, result.PhysicalTime);
Assert.Equal("test-node", result.NodeId);
Assert.Equal(42, result.LogicalCounter);
}
[Fact]
public void ObjectConverter_Deserialize_MissingPhysicalTime_ThrowsJsonException()
{
var options = new JsonSerializerOptions
{
Converters = { new HlcTimestampObjectJsonConverter() }
};
var json = """{"nodeId":"test-node","logicalCounter":42}""";
var ex = Assert.Throws<JsonException>(() =>
JsonSerializer.Deserialize<HlcTimestamp>(json, options));
Assert.Contains("physicalTime", ex.Message);
}
[Fact]
public void ObjectConverter_Deserialize_MissingNodeId_ThrowsJsonException()
{
var options = new JsonSerializerOptions
{
Converters = { new HlcTimestampObjectJsonConverter() }
};
var json = """{"physicalTime":1704067200000,"logicalCounter":42}""";
var ex = Assert.Throws<JsonException>(() =>
JsonSerializer.Deserialize<HlcTimestamp>(json, options));
Assert.Contains("nodeId", ex.Message);
}
[Fact]
public void ObjectConverter_Deserialize_MissingLogicalCounter_ThrowsJsonException()
{
var options = new JsonSerializerOptions
{
Converters = { new HlcTimestampObjectJsonConverter() }
};
var json = """{"physicalTime":1704067200000,"nodeId":"test-node"}""";
var ex = Assert.Throws<JsonException>(() =>
JsonSerializer.Deserialize<HlcTimestamp>(json, options));
Assert.Contains("logicalCounter", ex.Message);
}
[Fact]
public void ObjectConverter_Deserialize_ExtraProperties_Ignored()
{
var options = new JsonSerializerOptions
{
Converters = { new HlcTimestampObjectJsonConverter() }
};
var json = """{"physicalTime":1704067200000,"nodeId":"test-node","logicalCounter":42,"extra":"ignored"}""";
var result = JsonSerializer.Deserialize<HlcTimestamp>(json, options);
Assert.Equal(BasePhysicalTime, result.PhysicalTime);
Assert.Equal("test-node", result.NodeId);
Assert.Equal(42, result.LogicalCounter);
}
[Fact]
public void ObjectConverter_Deserialize_StringToken_ThrowsJsonException()
{
var options = new JsonSerializerOptions
{
Converters = { new HlcTimestampObjectJsonConverter() }
};
Assert.Throws<JsonException>(() =>
JsonSerializer.Deserialize<HlcTimestamp>("\"not-an-object\"", options));
}
[Fact]
public void ObjectConverter_RoundTrip_PreservesValues()
{
var options = new JsonSerializerOptions
{
Converters = { new HlcTimestampObjectJsonConverter() }
};
var original = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime,
NodeId = "scheduler-east-1",
LogicalCounter = 999999
};
var json = JsonSerializer.Serialize(original, options);
var deserialized = JsonSerializer.Deserialize<HlcTimestamp>(json, options);
Assert.Equal(original.PhysicalTime, deserialized.PhysicalTime);
Assert.Equal(original.NodeId, deserialized.NodeId);
Assert.Equal(original.LogicalCounter, deserialized.LogicalCounter);
}
#endregion
#region Object with HlcTimestamp Property Tests
[Fact]
public void StringConverter_InObject_SerializesCorrectly()
{
var options = new JsonSerializerOptions
{
Converters = { new HlcTimestampJsonConverter() }
};
var obj = new TestRecordWithTimestamp
{
Id = "test-123",
Timestamp = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime,
NodeId = "node",
LogicalCounter = 0
}
};
var json = JsonSerializer.Serialize(obj, options);
var doc = JsonDocument.Parse(json);
Assert.Equal("test-123", doc.RootElement.GetProperty("Id").GetString());
Assert.Equal("1704067200000-node-000000", doc.RootElement.GetProperty("Timestamp").GetString());
}
[Fact]
public void StringConverter_InObject_DeserializesCorrectly()
{
var options = new JsonSerializerOptions
{
Converters = { new HlcTimestampJsonConverter() }
};
var json = """{"Id":"test-123","Timestamp":"1704067200000-node-000042"}""";
var result = JsonSerializer.Deserialize<TestRecordWithTimestamp>(json, options);
Assert.NotNull(result);
Assert.Equal("test-123", result.Id);
Assert.Equal(BasePhysicalTime, result.Timestamp.PhysicalTime);
Assert.Equal("node", result.Timestamp.NodeId);
Assert.Equal(42, result.Timestamp.LogicalCounter);
}
#endregion
private sealed record TestRecordWithTimestamp
{
public required string Id { get; init; }
public required HlcTimestamp Timestamp { get; init; }
}
}

View File

@@ -0,0 +1,551 @@
// -----------------------------------------------------------------------------
// HlcTimestampTests.cs
// Sprint: SPRINT_20260105_002_001_LB_hlc_core_library
// Task: HLC-008 - Write unit tests for HLC
// -----------------------------------------------------------------------------
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.HybridLogicalClock.Tests;
/// <summary>
/// Unit tests for HlcTimestamp record struct.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public class HlcTimestampTests
{
private const string TestNodeId = "test-node-1";
private const long BasePhysicalTime = 1704067200000L; // 2024-01-01T00:00:00Z
#region ToSortableString Tests
[Fact]
public void ToSortableString_FormatsCorrectly()
{
var timestamp = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime,
NodeId = TestNodeId,
LogicalCounter = 42
};
var result = timestamp.ToSortableString();
Assert.Equal("1704067200000-test-node-1-000042", result);
}
[Fact]
public void ToSortableString_ZeroPadsPhysicalTime()
{
var timestamp = new HlcTimestamp
{
PhysicalTime = 123L,
NodeId = TestNodeId,
LogicalCounter = 0
};
var result = timestamp.ToSortableString();
Assert.StartsWith("0000000000123-", result);
}
[Fact]
public void ToSortableString_ZeroPadsCounter()
{
var timestamp = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime,
NodeId = TestNodeId,
LogicalCounter = 1
};
var result = timestamp.ToSortableString();
Assert.EndsWith("-000001", result);
}
#endregion
#region Parse Tests
[Fact]
public void Parse_ValidString_ReturnsTimestamp()
{
var result = HlcTimestamp.Parse("1704067200000-test-node-1-000042");
Assert.Equal(BasePhysicalTime, result.PhysicalTime);
Assert.Equal("test-node-1", result.NodeId);
Assert.Equal(42, result.LogicalCounter);
}
[Fact]
public void Parse_RoundTrip_PreservesValues()
{
var original = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime,
NodeId = "scheduler-east-1",
LogicalCounter = 999999
};
var serialized = original.ToSortableString();
var parsed = HlcTimestamp.Parse(serialized);
Assert.Equal(original.PhysicalTime, parsed.PhysicalTime);
Assert.Equal(original.NodeId, parsed.NodeId);
Assert.Equal(original.LogicalCounter, parsed.LogicalCounter);
}
[Fact]
public void Parse_NullString_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => HlcTimestamp.Parse(null!));
}
[Fact]
public void Parse_EmptyString_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => HlcTimestamp.Parse(""));
}
[Fact]
public void Parse_WhitespaceString_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => HlcTimestamp.Parse(" "));
}
[Fact]
public void Parse_InvalidFormat_ThrowsFormatException()
{
Assert.Throws<FormatException>(() => HlcTimestamp.Parse("invalid-format"));
}
[Fact]
public void Parse_MissingCounter_ThrowsFormatException()
{
Assert.Throws<FormatException>(() => HlcTimestamp.Parse("1704067200000-node"));
}
[Fact]
public void Parse_ShortPhysicalTime_ThrowsFormatException()
{
Assert.Throws<FormatException>(() => HlcTimestamp.Parse("170406720000-node-000042"));
}
[Fact]
public void Parse_ShortCounter_ThrowsFormatException()
{
Assert.Throws<FormatException>(() => HlcTimestamp.Parse("1704067200000-node-00042"));
}
#endregion
#region TryParse Tests
[Fact]
public void TryParse_ValidString_ReturnsTrue()
{
var success = HlcTimestamp.TryParse("1704067200000-test-node-1-000042", out var result);
Assert.True(success);
Assert.Equal(BasePhysicalTime, result.PhysicalTime);
Assert.Equal("test-node-1", result.NodeId);
Assert.Equal(42, result.LogicalCounter);
}
[Fact]
public void TryParse_InvalidString_ReturnsFalse()
{
var success = HlcTimestamp.TryParse("invalid", out var result);
Assert.False(success);
Assert.Equal(default, result);
}
[Fact]
public void TryParse_Null_ReturnsFalse()
{
var success = HlcTimestamp.TryParse(null, out var result);
Assert.False(success);
Assert.Equal(default, result);
}
[Fact]
public void TryParse_Empty_ReturnsFalse()
{
var success = HlcTimestamp.TryParse("", out var result);
Assert.False(success);
Assert.Equal(default, result);
}
#endregion
#region CompareTo Tests
[Fact]
public void CompareTo_EarlierPhysicalTime_ReturnsNegative()
{
var earlier = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime,
NodeId = TestNodeId,
LogicalCounter = 0
};
var later = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime + 1000,
NodeId = TestNodeId,
LogicalCounter = 0
};
Assert.True(earlier.CompareTo(later) < 0);
}
[Fact]
public void CompareTo_LaterPhysicalTime_ReturnsPositive()
{
var earlier = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime,
NodeId = TestNodeId,
LogicalCounter = 0
};
var later = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime + 1000,
NodeId = TestNodeId,
LogicalCounter = 0
};
Assert.True(later.CompareTo(earlier) > 0);
}
[Fact]
public void CompareTo_SamePhysicalTime_LowerCounter_ReturnsNegative()
{
var lower = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime,
NodeId = TestNodeId,
LogicalCounter = 5
};
var higher = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime,
NodeId = TestNodeId,
LogicalCounter = 10
};
Assert.True(lower.CompareTo(higher) < 0);
}
[Fact]
public void CompareTo_SamePhysicalTime_HigherCounter_ReturnsPositive()
{
var lower = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime,
NodeId = TestNodeId,
LogicalCounter = 5
};
var higher = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime,
NodeId = TestNodeId,
LogicalCounter = 10
};
Assert.True(higher.CompareTo(lower) > 0);
}
[Fact]
public void CompareTo_SamePhysicalTimeAndCounter_SortsLexicographicallyByNodeId()
{
var nodeA = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime,
NodeId = "node-a",
LogicalCounter = 0
};
var nodeB = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime,
NodeId = "node-b",
LogicalCounter = 0
};
Assert.True(nodeA.CompareTo(nodeB) < 0);
Assert.True(nodeB.CompareTo(nodeA) > 0);
}
[Fact]
public void CompareTo_IdenticalTimestamps_ReturnsZero()
{
var ts1 = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime,
NodeId = TestNodeId,
LogicalCounter = 42
};
var ts2 = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime,
NodeId = TestNodeId,
LogicalCounter = 42
};
Assert.Equal(0, ts1.CompareTo(ts2));
}
[Fact]
public void CompareTo_TotalOrdering_IsTransitive()
{
var a = new HlcTimestamp { PhysicalTime = 100, NodeId = "node", LogicalCounter = 0 };
var b = new HlcTimestamp { PhysicalTime = 100, NodeId = "node", LogicalCounter = 1 };
var c = new HlcTimestamp { PhysicalTime = 100, NodeId = "node", LogicalCounter = 2 };
// If a < b and b < c then a < c
Assert.True(a < b);
Assert.True(b < c);
Assert.True(a < c);
}
#endregion
#region Operator Tests
[Fact]
public void LessThanOperator_Works()
{
var earlier = new HlcTimestamp { PhysicalTime = 100, NodeId = "n", LogicalCounter = 0 };
var later = new HlcTimestamp { PhysicalTime = 200, NodeId = "n", LogicalCounter = 0 };
Assert.True(earlier < later);
Assert.False(later < earlier);
}
[Fact]
public void GreaterThanOperator_Works()
{
var earlier = new HlcTimestamp { PhysicalTime = 100, NodeId = "n", LogicalCounter = 0 };
var later = new HlcTimestamp { PhysicalTime = 200, NodeId = "n", LogicalCounter = 0 };
Assert.True(later > earlier);
Assert.False(earlier > later);
}
[Fact]
public void LessThanOrEqualOperator_Works()
{
var ts1 = new HlcTimestamp { PhysicalTime = 100, NodeId = "n", LogicalCounter = 0 };
var ts2 = new HlcTimestamp { PhysicalTime = 100, NodeId = "n", LogicalCounter = 0 };
var ts3 = new HlcTimestamp { PhysicalTime = 200, NodeId = "n", LogicalCounter = 0 };
Assert.True(ts1 <= ts2);
Assert.True(ts1 <= ts3);
Assert.False(ts3 <= ts1);
}
[Fact]
public void GreaterThanOrEqualOperator_Works()
{
var ts1 = new HlcTimestamp { PhysicalTime = 100, NodeId = "n", LogicalCounter = 0 };
var ts2 = new HlcTimestamp { PhysicalTime = 100, NodeId = "n", LogicalCounter = 0 };
var ts3 = new HlcTimestamp { PhysicalTime = 200, NodeId = "n", LogicalCounter = 0 };
Assert.True(ts1 >= ts2);
Assert.True(ts3 >= ts1);
Assert.False(ts1 >= ts3);
}
#endregion
#region IsBefore/IsAfter/IsConcurrent Tests
[Fact]
public void IsBefore_EarlierTimestamp_ReturnsTrue()
{
var earlier = new HlcTimestamp { PhysicalTime = 100, NodeId = "n", LogicalCounter = 0 };
var later = new HlcTimestamp { PhysicalTime = 200, NodeId = "n", LogicalCounter = 0 };
Assert.True(earlier.IsBefore(later));
Assert.False(later.IsBefore(earlier));
}
[Fact]
public void IsAfter_LaterTimestamp_ReturnsTrue()
{
var earlier = new HlcTimestamp { PhysicalTime = 100, NodeId = "n", LogicalCounter = 0 };
var later = new HlcTimestamp { PhysicalTime = 200, NodeId = "n", LogicalCounter = 0 };
Assert.True(later.IsAfter(earlier));
Assert.False(earlier.IsAfter(later));
}
[Fact]
public void IsConcurrent_SameTimeAndCounterDifferentNode_ReturnsTrue()
{
var nodeA = new HlcTimestamp { PhysicalTime = 100, NodeId = "node-a", LogicalCounter = 5 };
var nodeB = new HlcTimestamp { PhysicalTime = 100, NodeId = "node-b", LogicalCounter = 5 };
Assert.True(nodeA.IsConcurrent(nodeB));
Assert.True(nodeB.IsConcurrent(nodeA));
}
[Fact]
public void IsConcurrent_SameNode_ReturnsFalse()
{
var ts1 = new HlcTimestamp { PhysicalTime = 100, NodeId = "node", LogicalCounter = 5 };
var ts2 = new HlcTimestamp { PhysicalTime = 100, NodeId = "node", LogicalCounter = 5 };
Assert.False(ts1.IsConcurrent(ts2));
}
[Fact]
public void IsConcurrent_DifferentCounter_ReturnsFalse()
{
var ts1 = new HlcTimestamp { PhysicalTime = 100, NodeId = "node-a", LogicalCounter = 5 };
var ts2 = new HlcTimestamp { PhysicalTime = 100, NodeId = "node-b", LogicalCounter = 6 };
Assert.False(ts1.IsConcurrent(ts2));
}
#endregion
#region Increment/WithPhysicalTime Tests
[Fact]
public void Increment_IncreasesCounter()
{
var original = new HlcTimestamp { PhysicalTime = 100, NodeId = "n", LogicalCounter = 5 };
var incremented = original.Increment();
Assert.Equal(6, incremented.LogicalCounter);
Assert.Equal(original.PhysicalTime, incremented.PhysicalTime);
Assert.Equal(original.NodeId, incremented.NodeId);
}
[Fact]
public void WithPhysicalTime_UpdatesTimeAndResetsCounter()
{
var original = new HlcTimestamp { PhysicalTime = 100, NodeId = "n", LogicalCounter = 42 };
var updated = original.WithPhysicalTime(200);
Assert.Equal(200, updated.PhysicalTime);
Assert.Equal(0, updated.LogicalCounter);
Assert.Equal(original.NodeId, updated.NodeId);
}
#endregion
#region Now Tests
[Fact]
public void Now_CreatesTimestampWithZeroCounter()
{
var fakeTime = new FakeTimeProvider();
fakeTime.SetUtcNow(new DateTimeOffset(2024, 6, 15, 12, 0, 0, TimeSpan.Zero));
var timestamp = HlcTimestamp.Now("test-node", fakeTime);
Assert.Equal("test-node", timestamp.NodeId);
Assert.Equal(0, timestamp.LogicalCounter);
Assert.Equal(fakeTime.GetUtcNow().ToUnixTimeMilliseconds(), timestamp.PhysicalTime);
}
[Fact]
public void Now_NullNodeId_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() => HlcTimestamp.Now(null!, TimeProvider.System));
}
[Fact]
public void Now_NullTimeProvider_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() => HlcTimestamp.Now("node", null!));
}
#endregion
#region ToDateTimeOffset Tests
[Fact]
public void ToDateTimeOffset_ConvertsCorrectly()
{
var expected = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
var timestamp = new HlcTimestamp
{
PhysicalTime = expected.ToUnixTimeMilliseconds(),
NodeId = "n",
LogicalCounter = 0
};
var result = timestamp.ToDateTimeOffset();
Assert.Equal(expected, result);
}
#endregion
#region ToString Tests
[Fact]
public void ToString_ReturnsSortableString()
{
var timestamp = new HlcTimestamp
{
PhysicalTime = BasePhysicalTime,
NodeId = TestNodeId,
LogicalCounter = 42
};
Assert.Equal(timestamp.ToSortableString(), timestamp.ToString());
}
#endregion
#region Lexicographic Sorting Tests
[Fact]
public void ToSortableString_LexicographicOrder_MatchesLogicalOrder()
{
var timestamps = new[]
{
new HlcTimestamp { PhysicalTime = 100, NodeId = "node", LogicalCounter = 0 },
new HlcTimestamp { PhysicalTime = 100, NodeId = "node", LogicalCounter = 1 },
new HlcTimestamp { PhysicalTime = 101, NodeId = "node", LogicalCounter = 0 },
new HlcTimestamp { PhysicalTime = 200, NodeId = "node", LogicalCounter = 0 }
};
// Sort by string representation
var sortedByString = timestamps.OrderBy(t => t.ToSortableString()).ToList();
// Sort by logical comparison
var sortedByLogical = timestamps.OrderBy(t => t).ToList();
// Both orderings should match
for (var i = 0; i < timestamps.Length; i++)
{
Assert.Equal(sortedByLogical[i], sortedByString[i]);
}
}
#endregion
/// <summary>
/// Fake TimeProvider for deterministic testing.
/// </summary>
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);
}
}

View File

@@ -0,0 +1,450 @@
// -----------------------------------------------------------------------------
// HybridLogicalClockBenchmarks.cs
// Sprint: SPRINT_20260105_002_001_LB_hlc_core_library
// Task: HLC-010 - Write benchmarks: tick throughput, memory allocation
// -----------------------------------------------------------------------------
// Disable xUnit analyzer warning for async methods - cancellation token not relevant for these tests
#pragma warning disable xUnit1051
using System.Diagnostics;
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.HybridLogicalClock.Tests;
/// <summary>
/// Performance benchmarks for HLC operations.
/// </summary>
/// <remarks>
/// These tests measure tick throughput and memory allocation patterns.
/// They are tagged as Performance for CI filtering.
/// </remarks>
[Trait("Category", TestCategories.Performance)]
public class HybridLogicalClockBenchmarks
{
#region Tick Throughput Benchmarks
[Fact]
public void Tick_Throughput_SingleThread()
{
const int iterations = 100_000;
var timeProvider = new FakeTimeProvider();
var stateStore = new InMemoryHlcStateStore();
var clock = CreateClock(timeProvider, stateStore);
// Warmup
for (var i = 0; i < 1000; i++)
{
clock.Tick();
}
// Measure
var sw = Stopwatch.StartNew();
for (var i = 0; i < iterations; i++)
{
clock.Tick();
}
sw.Stop();
var ticksPerSecond = iterations / sw.Elapsed.TotalSeconds;
// Assert minimum performance threshold (at least 100K ticks/sec)
Assert.True(ticksPerSecond > 100_000,
$"Expected at least 100K ticks/sec but got {ticksPerSecond:N0}");
}
[Fact]
public async Task Tick_Throughput_MultiThread()
{
const int threads = 4;
const int iterationsPerThread = 25_000;
const int totalIterations = threads * iterationsPerThread;
var timeProvider = new FakeTimeProvider();
var stateStore = new InMemoryHlcStateStore();
var clock = CreateClock(timeProvider, stateStore);
// Warmup
for (var i = 0; i < 1000; i++)
{
clock.Tick();
}
// Measure
var sw = Stopwatch.StartNew();
var tasks = new List<Task>();
for (var t = 0; t < threads; t++)
{
tasks.Add(Task.Run(() =>
{
for (var i = 0; i < iterationsPerThread; i++)
{
clock.Tick();
}
}));
}
await Task.WhenAll(tasks);
sw.Stop();
var ticksPerSecond = totalIterations / sw.Elapsed.TotalSeconds;
// Assert minimum performance threshold (at least 50K ticks/sec under contention)
Assert.True(ticksPerSecond > 50_000,
$"Expected at least 50K ticks/sec but got {ticksPerSecond:N0}");
}
[Fact]
public void Tick_Throughput_WithTimeAdvance()
{
const int iterations = 50_000;
var timeProvider = new FakeTimeProvider();
var stateStore = new InMemoryHlcStateStore();
var clock = CreateClock(timeProvider, stateStore);
// Warmup
for (var i = 0; i < 1000; i++)
{
clock.Tick();
if (i % 100 == 0) timeProvider.Advance(TimeSpan.FromMilliseconds(1));
}
// Measure - simulate realistic scenario with occasional time advances
var sw = Stopwatch.StartNew();
for (var i = 0; i < iterations; i++)
{
clock.Tick();
if (i % 100 == 0)
{
timeProvider.Advance(TimeSpan.FromMilliseconds(1));
}
}
sw.Stop();
var ticksPerSecond = iterations / sw.Elapsed.TotalSeconds;
// Should still maintain good throughput
Assert.True(ticksPerSecond > 50_000,
$"Expected at least 50K ticks/sec but got {ticksPerSecond:N0}");
}
#endregion
#region Receive Throughput Benchmarks
[Fact]
public void Receive_Throughput_SingleThread()
{
const int iterations = 50_000;
var timeProvider = new FakeTimeProvider();
var stateStore = new InMemoryHlcStateStore();
var clock = CreateClock(timeProvider, stateStore);
// Pre-generate remote timestamps
var remoteTimestamps = new HlcTimestamp[iterations];
for (var i = 0; i < iterations; i++)
{
remoteTimestamps[i] = new HlcTimestamp
{
PhysicalTime = timeProvider.GetUtcNow().ToUnixTimeMilliseconds() + (i % 100),
NodeId = "remote-node",
LogicalCounter = i % 1000
};
}
// Warmup
for (var i = 0; i < 1000; i++)
{
clock.Receive(remoteTimestamps[i]);
}
// Measure
var sw = Stopwatch.StartNew();
for (var i = 0; i < iterations; i++)
{
clock.Receive(remoteTimestamps[i]);
}
sw.Stop();
var receivesPerSecond = iterations / sw.Elapsed.TotalSeconds;
// Assert minimum performance threshold
Assert.True(receivesPerSecond > 50_000,
$"Expected at least 50K receives/sec but got {receivesPerSecond:N0}");
}
#endregion
#region Parse/Serialize Throughput Benchmarks
[Fact]
public void Parse_Throughput()
{
const int iterations = 100_000;
const string testString = "1704067200000-scheduler-east-1-000042";
// Warmup
for (var i = 0; i < 1000; i++)
{
HlcTimestamp.Parse(testString);
}
// Measure
var sw = Stopwatch.StartNew();
for (var i = 0; i < iterations; i++)
{
HlcTimestamp.Parse(testString);
}
sw.Stop();
var parsesPerSecond = iterations / sw.Elapsed.TotalSeconds;
// Assert minimum performance threshold
Assert.True(parsesPerSecond > 500_000,
$"Expected at least 500K parses/sec but got {parsesPerSecond:N0}");
}
[Fact]
public void ToSortableString_Throughput()
{
const int iterations = 100_000;
var timestamp = new HlcTimestamp
{
PhysicalTime = 1704067200000L,
NodeId = "scheduler-east-1",
LogicalCounter = 42
};
// Warmup
for (var i = 0; i < 1000; i++)
{
_ = timestamp.ToSortableString();
}
// Measure
var sw = Stopwatch.StartNew();
for (var i = 0; i < iterations; i++)
{
_ = timestamp.ToSortableString();
}
sw.Stop();
var serializesPerSecond = iterations / sw.Elapsed.TotalSeconds;
// Assert minimum performance threshold
Assert.True(serializesPerSecond > 500_000,
$"Expected at least 500K serializes/sec but got {serializesPerSecond:N0}");
}
#endregion
#region Comparison Throughput Benchmarks
[Fact]
public void CompareTo_Throughput()
{
const int iterations = 1_000_000;
var ts1 = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 5 };
var ts2 = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-2", LogicalCounter = 10 };
// Warmup
for (var i = 0; i < 1000; i++)
{
_ = ts1.CompareTo(ts2);
}
// Measure
var sw = Stopwatch.StartNew();
for (var i = 0; i < iterations; i++)
{
_ = ts1.CompareTo(ts2);
}
sw.Stop();
var comparesPerSecond = iterations / sw.Elapsed.TotalSeconds;
// Assert minimum performance threshold (comparisons should be very fast)
Assert.True(comparesPerSecond > 10_000_000,
$"Expected at least 10M compares/sec but got {comparesPerSecond:N0}");
}
#endregion
#region Memory Allocation Tests
[Fact]
public void Tick_MemoryAllocation_Minimal()
{
var timeProvider = new FakeTimeProvider();
var stateStore = new InMemoryHlcStateStore();
var clock = CreateClock(timeProvider, stateStore);
// Force GC and get baseline
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
var beforeMemory = GC.GetTotalMemory(true);
// Generate many ticks
const int iterations = 10_000;
var timestamps = new HlcTimestamp[iterations];
for (var i = 0; i < iterations; i++)
{
timestamps[i] = clock.Tick();
}
// Measure memory increase
var afterMemory = GC.GetTotalMemory(false);
var memoryIncrease = afterMemory - beforeMemory;
var bytesPerTick = (double)memoryIncrease / iterations;
// HlcTimestamp is a struct (24 bytes: long + int + string reference)
// Array storage is expected, but tick operation itself should be minimal
var expectedMaxPerTick = 100; // Allow up to 100 bytes per tick including array storage
Assert.True(bytesPerTick < expectedMaxPerTick,
$"Memory per tick ({bytesPerTick:N0} bytes) exceeds threshold ({expectedMaxPerTick} bytes)");
}
[Fact]
public void HlcTimestamp_IsValueType()
{
// Verify HlcTimestamp is a struct (value type) for performance
Assert.True(typeof(HlcTimestamp).IsValueType,
"HlcTimestamp should be a value type (struct) for performance");
}
[Fact]
public void HlcTimestamp_Size_Reasonable()
{
// HlcTimestamp contains: long (8 bytes) + int (4 bytes) + string reference (8 bytes on 64-bit)
var timestamps = new HlcTimestamp[1000];
for (var i = 0; i < 1000; i++)
{
timestamps[i] = new HlcTimestamp
{
PhysicalTime = i,
NodeId = "node",
LogicalCounter = i
};
}
// Verify the array was created successfully
Assert.Equal(1000, timestamps.Length);
Assert.Equal(999, timestamps[999].PhysicalTime);
}
#endregion
#region State Store Throughput
[Fact]
public async Task InMemoryStateStore_Save_Throughput()
{
const int iterations = 50_000;
var stateStore = new InMemoryHlcStateStore();
// Warmup
for (var i = 0; i < 1000; i++)
{
await stateStore.SaveAsync(new HlcTimestamp
{
PhysicalTime = i,
NodeId = "test-node",
LogicalCounter = i
});
}
stateStore.Clear();
// Measure
var sw = Stopwatch.StartNew();
for (var i = 0; i < iterations; i++)
{
await stateStore.SaveAsync(new HlcTimestamp
{
PhysicalTime = i,
NodeId = "test-node",
LogicalCounter = i
});
}
sw.Stop();
var savesPerSecond = iterations / sw.Elapsed.TotalSeconds;
Assert.True(savesPerSecond > 100_000,
$"Expected at least 100K saves/sec but got {savesPerSecond:N0}");
}
[Fact]
public async Task InMemoryStateStore_Load_Throughput()
{
const int iterations = 100_000;
var stateStore = new InMemoryHlcStateStore();
// Pre-populate
await stateStore.SaveAsync(new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "test-node",
LogicalCounter = 0
});
// Warmup
for (var i = 0; i < 1000; i++)
{
await stateStore.LoadAsync("test-node");
}
// Measure
var sw = Stopwatch.StartNew();
for (var i = 0; i < iterations; i++)
{
await stateStore.LoadAsync("test-node");
}
sw.Stop();
var loadsPerSecond = iterations / sw.Elapsed.TotalSeconds;
Assert.True(loadsPerSecond > 500_000,
$"Expected at least 500K loads/sec but got {loadsPerSecond:N0}");
}
#endregion
#region Helper Methods
private static HybridLogicalClock CreateClock(
TimeProvider timeProvider,
IHlcStateStore stateStore)
{
return new HybridLogicalClock(
timeProvider,
"test-node",
stateStore,
NullLogger<HybridLogicalClock>.Instance);
}
#endregion
/// <summary>
/// Fake TimeProvider for deterministic testing.
/// </summary>
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);
}
}

View File

@@ -0,0 +1,409 @@
// -----------------------------------------------------------------------------
// HybridLogicalClockIntegrationTests.cs
// Sprint: SPRINT_20260105_002_001_LB_hlc_core_library
// Task: HLC-009 - Write integration tests: concurrent ticks, node restart recovery
// -----------------------------------------------------------------------------
// Disable xUnit analyzer warning for async methods - cancellation token not relevant for these tests
#pragma warning disable xUnit1051
using Microsoft.Extensions.Logging.Abstractions;
using StellaOps.TestKit;
using Xunit;
namespace StellaOps.HybridLogicalClock.Tests;
/// <summary>
/// Integration tests for HLC concurrent and multi-node scenarios.
/// </summary>
[Trait("Category", TestCategories.Integration)]
public class HybridLogicalClockIntegrationTests
{
private const string TestNodeId = "test-node";
#region Concurrent Ticks Tests
[Fact]
public async Task ConcurrentTicks_AllUnique_SingleClock()
{
var timeProvider = new FakeTimeProvider();
var stateStore = new InMemoryHlcStateStore();
var clock = CreateClock(timeProvider, stateStore);
var timestamps = new System.Collections.Concurrent.ConcurrentBag<HlcTimestamp>();
var tasks = new List<Task>();
// Generate 1000 concurrent ticks from multiple threads
for (var i = 0; i < 100; i++)
{
tasks.Add(Task.Run(() =>
{
for (var j = 0; j < 10; j++)
{
timestamps.Add(clock.Tick());
}
}));
}
await Task.WhenAll(tasks);
// Verify all timestamps are unique
var uniqueStrings = timestamps.Select(t => t.ToSortableString()).ToHashSet();
Assert.Equal(1000, uniqueStrings.Count);
}
[Fact]
public async Task ConcurrentTicks_AllMonotonic_WithinThread()
{
var timeProvider = new FakeTimeProvider();
var stateStore = new InMemoryHlcStateStore();
var clock = CreateClock(timeProvider, stateStore);
var tasksCompleted = 0;
var tasks = new List<Task>();
// Run multiple threads, each generating sequential ticks
for (var threadId = 0; threadId < 10; threadId++)
{
tasks.Add(Task.Run(() =>
{
var localTimestamps = new List<HlcTimestamp>();
for (var i = 0; i < 100; i++)
{
localTimestamps.Add(clock.Tick());
}
// Verify monotonicity within this thread's sequence
for (var i = 1; i < localTimestamps.Count; i++)
{
Assert.True(localTimestamps[i] > localTimestamps[i - 1],
$"Monotonicity violated at index {i}");
}
Interlocked.Increment(ref tasksCompleted);
}));
}
await Task.WhenAll(tasks);
Assert.Equal(10, tasksCompleted);
}
#endregion
#region Node Restart Recovery Tests
[Fact]
public async Task NodeRestart_ResumesFromPersisted_InMemory()
{
// Shared state store simulates persistent storage
var stateStore = new InMemoryHlcStateStore();
// First instance - generate some ticks
var timeProvider1 = new FakeTimeProvider();
timeProvider1.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
var clock1 = CreateClock(timeProvider1, stateStore);
var lastTickBeforeRestart = clock1.Tick();
// Simulate multiple ticks
for (var i = 0; i < 10; i++)
{
clock1.Tick();
}
var finalTickBeforeRestart = clock1.Current;
// Give async persistence time to complete
await Task.Delay(50);
// "Restart" - create new clock instance with same state store
var timeProvider2 = new FakeTimeProvider();
timeProvider2.SetUtcNow(new DateTimeOffset(2024, 1, 1, 0, 0, 1, TimeSpan.Zero)); // 1 second later
var clock2 = CreateClock(timeProvider2, stateStore);
var recovered = await clock2.InitializeFromStateAsync();
Assert.True(recovered, "Should have recovered from persisted state");
// First tick after restart should be greater than last tick before restart
var firstTickAfterRestart = clock2.Tick();
Assert.True(firstTickAfterRestart > finalTickBeforeRestart,
$"First tick after restart ({firstTickAfterRestart}) should be > last tick before restart ({finalTickBeforeRestart})");
}
[Fact]
public async Task NodeRestart_SamePhysicalTime_IncrementCounter()
{
var stateStore = new InMemoryHlcStateStore();
var fixedTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
// First instance
var timeProvider1 = new FakeTimeProvider();
timeProvider1.SetUtcNow(fixedTime);
var clock1 = CreateClock(timeProvider1, stateStore);
var tick1 = clock1.Tick(); // Counter = 0
await Task.Delay(50); // Allow persistence
// "Restart" with same physical time
var timeProvider2 = new FakeTimeProvider();
timeProvider2.SetUtcNow(fixedTime);
var clock2 = CreateClock(timeProvider2, stateStore);
await clock2.InitializeFromStateAsync();
// First tick should have incremented counter since physical time is same
var tick2 = clock2.Tick();
Assert.Equal(tick1.PhysicalTime, tick2.PhysicalTime);
Assert.True(tick2.LogicalCounter > tick1.LogicalCounter,
$"Counter should be greater: {tick2.LogicalCounter} > {tick1.LogicalCounter}");
}
#endregion
#region Multi-Node Causal Ordering Tests
[Fact]
public void MultiNode_CausalOrdering_RequestResponse()
{
var baseTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
var timeProvider1 = new FakeTimeProvider();
var timeProvider2 = new FakeTimeProvider();
timeProvider1.SetUtcNow(baseTime);
timeProvider2.SetUtcNow(baseTime);
var clock1 = CreateClock(timeProvider1, new InMemoryHlcStateStore(), nodeId: "node-1");
var clock2 = CreateClock(timeProvider2, new InMemoryHlcStateStore(), nodeId: "node-2");
// Node 1 sends request
var requestTs = clock1.Tick();
// Node 2 receives request
var node2AfterReceive = clock2.Receive(requestTs);
// Node 2 sends response
var responseTs = clock2.Tick();
// Node 1 receives response
var node1AfterReceive = clock1.Receive(responseTs);
// Verify causal ordering
Assert.True(requestTs < node2AfterReceive, "Request < Node2 after receive");
Assert.True(node2AfterReceive < responseTs, "Node2 after receive < Response");
Assert.True(responseTs < node1AfterReceive, "Response < Node1 after receive");
// The entire chain should be causally ordered
Assert.True(requestTs < node1AfterReceive, "Request < final");
}
[Fact]
public void MultiNode_CausalOrdering_BroadcastGather()
{
var baseTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
// Create 5 node clocks
var clocks = new List<HybridLogicalClock>();
var timeProviders = new List<FakeTimeProvider>();
for (var i = 0; i < 5; i++)
{
var tp = new FakeTimeProvider();
tp.SetUtcNow(baseTime);
timeProviders.Add(tp);
clocks.Add(CreateClock(tp, new InMemoryHlcStateStore(), nodeId: $"node-{i}"));
}
// Node 0 broadcasts to all others
var broadcast = clocks[0].Tick();
var receivedTimestamps = new List<HlcTimestamp>();
for (var i = 1; i < 5; i++)
{
receivedTimestamps.Add(clocks[i].Receive(broadcast));
}
// All received timestamps should be > broadcast
foreach (var received in receivedTimestamps)
{
Assert.True(received > broadcast);
}
// Each node responds
var responses = new List<HlcTimestamp>();
for (var i = 1; i < 5; i++)
{
responses.Add(clocks[i].Tick());
}
// Node 0 gathers all responses
var maxResponse = responses[0];
foreach (var response in responses)
{
var gathered = clocks[0].Receive(response);
if (gathered > maxResponse) maxResponse = gathered;
}
var final = clocks[0].Tick();
// Final timestamp should be > broadcast and > all responses
Assert.True(final > broadcast);
foreach (var response in responses)
{
Assert.True(final > response);
}
}
[Fact]
public void MultiNode_ClockSkew_DetectedAndRejected()
{
var baseTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
var maxSkew = TimeSpan.FromSeconds(30);
var tp1 = new FakeTimeProvider();
var tp2 = new FakeTimeProvider();
tp1.SetUtcNow(baseTime);
tp2.SetUtcNow(baseTime.AddMinutes(2)); // 2 minutes ahead
var clock1 = CreateClock(tp1, new InMemoryHlcStateStore(), maxSkew, "node-1");
var clock2 = CreateClock(tp2, new InMemoryHlcStateStore(), maxSkew, "node-2");
var ts2 = clock2.Tick();
// Clock 1 should reject timestamp from clock 2 due to excessive skew
var exception = Assert.Throws<HlcClockSkewException>(() => clock1.Receive(ts2));
Assert.True(exception.ActualSkew > maxSkew);
}
[Fact]
public void MultiNode_ConcurrentEvents_StillTotallyOrdered()
{
var baseTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
var tp1 = new FakeTimeProvider();
var tp2 = new FakeTimeProvider();
tp1.SetUtcNow(baseTime);
tp2.SetUtcNow(baseTime);
var clock1 = CreateClock(tp1, new InMemoryHlcStateStore(), nodeId: "node-1");
var clock2 = CreateClock(tp2, new InMemoryHlcStateStore(), nodeId: "node-2");
// Generate concurrent events (no communication)
var events = new List<HlcTimestamp>();
for (var i = 0; i < 10; i++)
{
events.Add(clock1.Tick());
events.Add(clock2.Tick());
}
// Even concurrent events can be totally ordered
var sorted = events.OrderBy(e => e).ToList();
// No two elements should be equal (total ordering)
for (var i = 1; i < sorted.Count; i++)
{
Assert.NotEqual(sorted[i], sorted[i - 1]);
}
}
#endregion
#region State Store Concurrency Tests
[Fact]
public async Task InMemoryStateStore_ConcurrentSaves_NoLoss()
{
var stateStore = new InMemoryHlcStateStore();
var tasks = new List<Task>();
// Multiple concurrent saves with increasing timestamps
for (var i = 0; i < 100; i++)
{
var timestamp = new HlcTimestamp
{
PhysicalTime = 1000 + i,
NodeId = TestNodeId,
LogicalCounter = i
};
tasks.Add(stateStore.SaveAsync(timestamp));
}
await Task.WhenAll(tasks);
// The final state should be the highest timestamp
var stored = await stateStore.LoadAsync(TestNodeId);
Assert.NotNull(stored);
Assert.True(stored.Value.PhysicalTime >= 1099, "Should have the highest physical time");
}
[Fact]
public async Task InMemoryStateStore_ConcurrentSaves_MaintainsMonotonicity()
{
var stateStore = new InMemoryHlcStateStore();
// Save a high timestamp first
var highTs = new HlcTimestamp { PhysicalTime = 10000, NodeId = TestNodeId, LogicalCounter = 0 };
await stateStore.SaveAsync(highTs);
var tasks = new List<Task>();
// Concurrent saves with lower timestamps
for (var i = 0; i < 50; i++)
{
var lowTs = new HlcTimestamp
{
PhysicalTime = 5000 + i,
NodeId = TestNodeId,
LogicalCounter = i
};
tasks.Add(stateStore.SaveAsync(lowTs));
}
await Task.WhenAll(tasks);
// High timestamp should still be preserved
var stored = await stateStore.LoadAsync(TestNodeId);
Assert.NotNull(stored);
Assert.Equal(highTs, stored.Value);
}
#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<HybridLogicalClock>.Instance,
maxSkew);
}
#endregion
/// <summary>
/// Fake TimeProvider for deterministic testing.
/// </summary>
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);
}
}

View File

@@ -0,0 +1,545 @@
// -----------------------------------------------------------------------------
// 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;
/// <summary>
/// Unit tests for HybridLogicalClock class.
/// </summary>
[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<HlcTimestamp>();
// 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<string>();
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<HlcClockSkewException>(() => 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<HlcClockSkewException>(() => 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<ArgumentNullException>(() =>
new HybridLogicalClock(
null!,
TestNodeId,
new InMemoryHlcStateStore(),
NullLogger<HybridLogicalClock>.Instance));
}
[Fact]
public void Constructor_NullNodeId_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() =>
new HybridLogicalClock(
TimeProvider.System,
null!,
new InMemoryHlcStateStore(),
NullLogger<HybridLogicalClock>.Instance));
}
[Fact]
public void Constructor_EmptyNodeId_ThrowsArgumentException()
{
Assert.Throws<ArgumentException>(() =>
new HybridLogicalClock(
TimeProvider.System,
"",
new InMemoryHlcStateStore(),
NullLogger<HybridLogicalClock>.Instance));
}
[Fact]
public void Constructor_NullStateStore_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() =>
new HybridLogicalClock(
TimeProvider.System,
TestNodeId,
null!,
NullLogger<HybridLogicalClock>.Instance));
}
[Fact]
public void Constructor_NullLogger_ThrowsArgumentNullException()
{
Assert.Throws<ArgumentNullException>(() =>
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<HybridLogicalClock>.Instance,
maxSkew);
}
#endregion
/// <summary>
/// Fake TimeProvider for deterministic testing.
/// </summary>
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);
}
}

View File

@@ -0,0 +1,285 @@
// -----------------------------------------------------------------------------
// InMemoryHlcStateStoreTests.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 StellaOps.TestKit;
using Xunit;
namespace StellaOps.HybridLogicalClock.Tests;
/// <summary>
/// Unit tests for InMemoryHlcStateStore.
/// </summary>
[Trait("Category", TestCategories.Unit)]
public class InMemoryHlcStateStoreTests
{
#region LoadAsync Tests
[Fact]
public async Task LoadAsync_NoState_ReturnsNull()
{
var store = new InMemoryHlcStateStore();
var result = await store.LoadAsync("node-1");
Assert.Null(result);
}
[Fact]
public async Task LoadAsync_ExistingState_ReturnsTimestamp()
{
var store = new InMemoryHlcStateStore();
var timestamp = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node-1",
LogicalCounter = 5
};
await store.SaveAsync(timestamp);
var result = await store.LoadAsync("node-1");
Assert.NotNull(result);
Assert.Equal(timestamp.PhysicalTime, result.Value.PhysicalTime);
Assert.Equal(timestamp.NodeId, result.Value.NodeId);
Assert.Equal(timestamp.LogicalCounter, result.Value.LogicalCounter);
}
[Fact]
public async Task LoadAsync_DifferentNode_ReturnsNull()
{
var store = new InMemoryHlcStateStore();
var timestamp = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node-1",
LogicalCounter = 5
};
await store.SaveAsync(timestamp);
var result = await store.LoadAsync("node-2");
Assert.Null(result);
}
[Fact]
public async Task LoadAsync_NullNodeId_ThrowsArgumentException()
{
var store = new InMemoryHlcStateStore();
await Assert.ThrowsAsync<ArgumentException>(() => store.LoadAsync(null!));
}
[Fact]
public async Task LoadAsync_EmptyNodeId_ThrowsArgumentException()
{
var store = new InMemoryHlcStateStore();
await Assert.ThrowsAsync<ArgumentException>(() => store.LoadAsync(""));
}
[Fact]
public async Task LoadAsync_CancellationRequested_ThrowsOperationCanceledException()
{
var store = new InMemoryHlcStateStore();
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(() =>
store.LoadAsync("node-1", cts.Token));
}
#endregion
#region SaveAsync Tests
[Fact]
public async Task SaveAsync_NewTimestamp_StoresIt()
{
var store = new InMemoryHlcStateStore();
var timestamp = new HlcTimestamp
{
PhysicalTime = 1000,
NodeId = "node-1",
LogicalCounter = 5
};
await store.SaveAsync(timestamp);
var result = await store.LoadAsync("node-1");
Assert.NotNull(result);
Assert.Equal(timestamp, result.Value);
}
[Fact]
public async Task SaveAsync_GreaterTimestamp_UpdatesStore()
{
var store = new InMemoryHlcStateStore();
var ts1 = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 5 };
var ts2 = new HlcTimestamp { PhysicalTime = 2000, NodeId = "node-1", LogicalCounter = 0 };
await store.SaveAsync(ts1);
await store.SaveAsync(ts2);
var result = await store.LoadAsync("node-1");
Assert.NotNull(result);
Assert.Equal(ts2, result.Value);
}
[Fact]
public async Task SaveAsync_SmallerTimestamp_DoesNotUpdateStore()
{
var store = new InMemoryHlcStateStore();
var ts1 = new HlcTimestamp { PhysicalTime = 2000, NodeId = "node-1", LogicalCounter = 0 };
var ts2 = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 99 };
await store.SaveAsync(ts1);
await store.SaveAsync(ts2);
var result = await store.LoadAsync("node-1");
Assert.NotNull(result);
Assert.Equal(ts1, result.Value); // Original is kept
}
[Fact]
public async Task SaveAsync_MultipleNodes_StoresSeparately()
{
var store = new InMemoryHlcStateStore();
var ts1 = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 0 };
var ts2 = new HlcTimestamp { PhysicalTime = 2000, NodeId = "node-2", LogicalCounter = 0 };
await store.SaveAsync(ts1);
await store.SaveAsync(ts2);
var result1 = await store.LoadAsync("node-1");
var result2 = await store.LoadAsync("node-2");
Assert.NotNull(result1);
Assert.NotNull(result2);
Assert.Equal(ts1, result1.Value);
Assert.Equal(ts2, result2.Value);
}
[Fact]
public async Task SaveAsync_CancellationRequested_ThrowsOperationCanceledException()
{
var store = new InMemoryHlcStateStore();
var timestamp = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 0 };
using var cts = new CancellationTokenSource();
cts.Cancel();
await Assert.ThrowsAsync<OperationCanceledException>(() =>
store.SaveAsync(timestamp, cts.Token));
}
#endregion
#region GetAllStates Tests
[Fact]
public void GetAllStates_EmptyStore_ReturnsEmptyDictionary()
{
var store = new InMemoryHlcStateStore();
var result = store.GetAllStates();
Assert.Empty(result);
}
[Fact]
public async Task GetAllStates_WithStates_ReturnsAllStates()
{
var store = new InMemoryHlcStateStore();
var ts1 = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 0 };
var ts2 = new HlcTimestamp { PhysicalTime = 2000, NodeId = "node-2", LogicalCounter = 0 };
await store.SaveAsync(ts1);
await store.SaveAsync(ts2);
var result = store.GetAllStates();
Assert.Equal(2, result.Count);
Assert.True(result.ContainsKey("node-1"));
Assert.True(result.ContainsKey("node-2"));
}
[Fact]
public async Task GetAllStates_ReturnsDefensiveCopy()
{
var store = new InMemoryHlcStateStore();
var ts = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 0 };
await store.SaveAsync(ts);
var states1 = store.GetAllStates();
var states2 = store.GetAllStates();
// Should be different dictionary instances
Assert.NotSame(states1, states2);
}
#endregion
#region Clear Tests
[Fact]
public async Task Clear_RemovesAllStates()
{
var store = new InMemoryHlcStateStore();
await store.SaveAsync(new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 0 });
await store.SaveAsync(new HlcTimestamp { PhysicalTime = 2000, NodeId = "node-2", LogicalCounter = 0 });
store.Clear();
var result = store.GetAllStates();
Assert.Empty(result);
var loaded = await store.LoadAsync("node-1");
Assert.Null(loaded);
}
#endregion
#region Monotonicity Tests
[Fact]
public async Task SaveAsync_MaintainsMonotonicity_SamePhysicalTimeHigherCounter()
{
var store = new InMemoryHlcStateStore();
var ts1 = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 5 };
var ts2 = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 10 };
await store.SaveAsync(ts1);
await store.SaveAsync(ts2);
var result = await store.LoadAsync("node-1");
Assert.Equal(10, result!.Value.LogicalCounter);
}
[Fact]
public async Task SaveAsync_MaintainsMonotonicity_SamePhysicalTimeLowerCounter()
{
var store = new InMemoryHlcStateStore();
var ts1 = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 10 };
var ts2 = new HlcTimestamp { PhysicalTime = 1000, NodeId = "node-1", LogicalCounter = 5 };
await store.SaveAsync(ts1);
await store.SaveAsync(ts2);
var result = await store.LoadAsync("node-1");
Assert.Equal(10, result!.Value.LogicalCounter); // Original kept
}
#endregion
}

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<LangVersion>preview</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.v3.assert" />
<PackageReference Include="xunit.runner.visualstudio" PrivateAssets="all" />
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Moq" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="../../StellaOps.HybridLogicalClock/StellaOps.HybridLogicalClock.csproj" />
<ProjectReference Include="../../StellaOps.TestKit/StellaOps.TestKit.csproj" />
</ItemGroup>
</Project>