stabilizaiton work - projects rework for maintenanceability and ui livening
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
// <copyright file="HlcTimestampJsonConverterTests.Deserialize.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
public sealed partial class HlcTimestampJsonConverterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Deserialize_ParsesSortableString()
|
||||
{
|
||||
var json = "\"1704067200000-node1-000042\"";
|
||||
|
||||
var result = JsonSerializer.Deserialize<HlcTimestamp>(json, _options);
|
||||
|
||||
result.PhysicalTime.Should().Be(1704067200000);
|
||||
result.NodeId.Should().Be("node1");
|
||||
result.LogicalCounter.Should().Be(42);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void RoundTrip_PreservesValues()
|
||||
{
|
||||
var original = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = 1704067200000,
|
||||
NodeId = "scheduler-east-1",
|
||||
LogicalCounter = 999
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(original, _options);
|
||||
var deserialized = JsonSerializer.Deserialize<HlcTimestamp>(json, _options);
|
||||
|
||||
deserialized.Should().Be(original);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_Null_ThrowsJsonException()
|
||||
{
|
||||
var json = "null";
|
||||
|
||||
var act = () => JsonSerializer.Deserialize<HlcTimestamp>(json, _options);
|
||||
|
||||
// Converter intentionally throws; use NullableHlcTimestampJsonConverter for nulls.
|
||||
act.Should().Throw<JsonException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_InvalidFormat_ThrowsJsonException()
|
||||
{
|
||||
var json = "\"invalid\"";
|
||||
|
||||
var act = () => JsonSerializer.Deserialize<HlcTimestamp>(json, _options);
|
||||
|
||||
act.Should().Throw<JsonException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Deserialize_WrongTokenType_ThrowsJsonException()
|
||||
{
|
||||
var json = "12345";
|
||||
|
||||
var act = () => JsonSerializer.Deserialize<HlcTimestamp>(json, _options);
|
||||
|
||||
act.Should().Throw<JsonException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// <copyright file="HlcTimestampJsonConverterTests.Serialize.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using System.Text.Json;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
public sealed partial class HlcTimestampJsonConverterTests
|
||||
{
|
||||
[Fact]
|
||||
public void Serialize_ProducesSortableString()
|
||||
{
|
||||
var timestamp = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = 1704067200000,
|
||||
NodeId = "node1",
|
||||
LogicalCounter = 42
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(timestamp, _options);
|
||||
|
||||
json.Should().Be("\"1704067200000-node1-000042\"");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeInObject_WorksCorrectly()
|
||||
{
|
||||
var obj = new TestWrapper
|
||||
{
|
||||
Timestamp = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = 1704067200000,
|
||||
NodeId = "node1",
|
||||
LogicalCounter = 1
|
||||
},
|
||||
Name = "Test"
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(obj, _options);
|
||||
var deserialized = JsonSerializer.Deserialize<TestWrapper>(json, _options);
|
||||
|
||||
deserialized.Should().NotBeNull();
|
||||
deserialized!.Timestamp.Should().Be(obj.Timestamp);
|
||||
deserialized.Name.Should().Be(obj.Name);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
// <copyright file="HlcTimestampJsonConverterTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
@@ -11,129 +10,13 @@ namespace StellaOps.HybridLogicalClock.Tests;
|
||||
/// Unit tests for <see cref="HlcTimestampJsonConverter"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HlcTimestampJsonConverterTests
|
||||
public sealed partial 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_ThrowsJsonException()
|
||||
{
|
||||
// Arrange
|
||||
var json = "null";
|
||||
|
||||
// Act
|
||||
var act = () => JsonSerializer.Deserialize<HlcTimestamp>(json, _options);
|
||||
|
||||
// Assert — converter intentionally throws; use NullableHlcTimestampJsonConverter for nullable timestamps.
|
||||
act.Should().Throw<JsonException>();
|
||||
}
|
||||
|
||||
[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; }
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
// <copyright file="HlcTimestampTests.Causality.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
public sealed partial class HlcTimestampTests
|
||||
{
|
||||
[Fact]
|
||||
public void IsBefore_And_IsAfter_RespectOrdering()
|
||||
{
|
||||
var earlier = CreateTimestamp(1000, "node1", 1);
|
||||
var later = CreateTimestamp(1000, "node1", 2);
|
||||
|
||||
earlier.IsBefore(later).Should().BeTrue();
|
||||
later.IsAfter(earlier).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void IsConcurrent_SameTimeCounterDifferentNode_ReturnsTrue()
|
||||
{
|
||||
var a = CreateTimestamp(1000, "node-a", 3);
|
||||
var b = CreateTimestamp(1000, "node-b", 3);
|
||||
|
||||
a.IsConcurrent(b).Should().BeTrue();
|
||||
b.IsConcurrent(a).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// <copyright file="HlcTimestampTests.Compare.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
public sealed partial class HlcTimestampTests
|
||||
{
|
||||
[Fact]
|
||||
public void CompareTo_SamePhysicalTime_HigherCounterIsGreater()
|
||||
{
|
||||
var earlier = CreateTimestamp(1000, "node1", 1);
|
||||
var later = CreateTimestamp(1000, "node1", 2);
|
||||
|
||||
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()
|
||||
{
|
||||
var earlier = CreateTimestamp(1000, "node1", 999);
|
||||
var later = CreateTimestamp(1001, "node1", 0);
|
||||
|
||||
earlier.CompareTo(later).Should().BeLessThan(0);
|
||||
later.CompareTo(earlier).Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareTo_SameTimeAndCounter_NodeIdBreaksTie()
|
||||
{
|
||||
var a = CreateTimestamp(1000, "aaa", 1);
|
||||
var b = CreateTimestamp(1000, "bbb", 1);
|
||||
|
||||
a.CompareTo(b).Should().BeLessThan(0);
|
||||
b.CompareTo(a).Should().BeGreaterThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareTo_Equal_ReturnsZero()
|
||||
{
|
||||
var a = CreateTimestamp(1000, "node1", 1);
|
||||
var b = CreateTimestamp(1000, "node1", 1);
|
||||
|
||||
a.CompareTo(b).Should().Be(0);
|
||||
(a <= b).Should().BeTrue();
|
||||
(a >= b).Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Default_HasExpectedValues()
|
||||
{
|
||||
var zero = default(HlcTimestamp);
|
||||
|
||||
zero.PhysicalTime.Should().Be(0);
|
||||
zero.NodeId.Should().BeNull();
|
||||
zero.LogicalCounter.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareTo_DefaultTimestamp_ReturnsPositiveForNonDefault()
|
||||
{
|
||||
var timestamp = CreateTimestamp(1000, "node1", 1);
|
||||
var defaultTimestamp = default(HlcTimestamp);
|
||||
|
||||
var result = timestamp.CompareTo(defaultTimestamp);
|
||||
|
||||
result.Should().BeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
// <copyright file="HlcTimestampTests.Conversion.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
public sealed partial class HlcTimestampTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToDateTimeOffset_ConvertsCorrectly()
|
||||
{
|
||||
var timestamp = CreateTimestamp(1704067200000, "node1", 0);
|
||||
|
||||
var dateTime = timestamp.ToDateTimeOffset();
|
||||
|
||||
dateTime.Should().Be(new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
// <copyright file="HlcTimestampTests.Equality.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
public sealed partial class HlcTimestampTests
|
||||
{
|
||||
[Fact]
|
||||
public void Equality_SameValues_AreEqual()
|
||||
{
|
||||
var a = CreateTimestamp(1000, "node1", 1);
|
||||
var b = CreateTimestamp(1000, "node1", 1);
|
||||
|
||||
a.Should().Be(b);
|
||||
(a == b).Should().BeTrue();
|
||||
a.GetHashCode().Should().Be(b.GetHashCode());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Equality_DifferentValues_AreNotEqual()
|
||||
{
|
||||
var a = CreateTimestamp(1000, "node1", 1);
|
||||
var b = CreateTimestamp(1000, "node1", 2);
|
||||
|
||||
a.Should().NotBe(b);
|
||||
(a != b).Should().BeTrue();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// <copyright file="HlcTimestampTests.Format.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
public sealed partial class HlcTimestampTests
|
||||
{
|
||||
[Fact]
|
||||
public void ToSortableString_FormatsCorrectly()
|
||||
{
|
||||
var timestamp = CreateTimestamp(1704067200000, "scheduler-east-1", 42);
|
||||
|
||||
var result = timestamp.ToSortableString();
|
||||
|
||||
result.Should().Be("1704067200000-scheduler-east-1-000042");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToString_ReturnsSortableString()
|
||||
{
|
||||
var timestamp = CreateTimestamp(1704067200000, "node1", 42);
|
||||
|
||||
var result = timestamp.ToString();
|
||||
|
||||
result.Should().Be(timestamp.ToSortableString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// <copyright file="HlcTimestampTests.Helpers.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
public sealed partial class HlcTimestampTests
|
||||
{
|
||||
private static HlcTimestamp CreateTimestamp(long physicalTime, string nodeId, int counter)
|
||||
{
|
||||
return new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = physicalTime,
|
||||
NodeId = nodeId,
|
||||
LogicalCounter = counter
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// <copyright file="HlcTimestampTests.Mutation.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
public sealed partial class HlcTimestampTests
|
||||
{
|
||||
[Fact]
|
||||
public void Increment_IncreasesLogicalCounter()
|
||||
{
|
||||
var original = CreateTimestamp(1000, "node1", 4);
|
||||
|
||||
var incremented = original.Increment();
|
||||
|
||||
incremented.PhysicalTime.Should().Be(original.PhysicalTime);
|
||||
incremented.NodeId.Should().Be(original.NodeId);
|
||||
incremented.LogicalCounter.Should().Be(5);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WithPhysicalTime_ResetsCounter()
|
||||
{
|
||||
var original = CreateTimestamp(1000, "node1", 4);
|
||||
|
||||
var updated = original.WithPhysicalTime(2000);
|
||||
|
||||
updated.PhysicalTime.Should().Be(2000);
|
||||
updated.NodeId.Should().Be("node1");
|
||||
updated.LogicalCounter.Should().Be(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// <copyright file="HlcTimestampTests.Now.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
public sealed partial class HlcTimestampTests
|
||||
{
|
||||
[Fact]
|
||||
public void Now_UsesTimeProviderAndSetsNodeId()
|
||||
{
|
||||
var fixedTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FakeTimeProvider(fixedTime);
|
||||
|
||||
var timestamp = HlcTimestamp.Now("node-1", timeProvider);
|
||||
|
||||
timestamp.PhysicalTime.Should().Be(fixedTime.ToUnixTimeMilliseconds());
|
||||
timestamp.NodeId.Should().Be("node-1");
|
||||
timestamp.LogicalCounter.Should().Be(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// <copyright file="HlcTimestampTests.Parse.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
public sealed partial class HlcTimestampTests
|
||||
{
|
||||
[Fact]
|
||||
public void Parse_RoundTrip_PreservesValues()
|
||||
{
|
||||
var original = CreateTimestamp(1704067200000, "scheduler-east-1", 42);
|
||||
|
||||
var serialized = original.ToSortableString();
|
||||
var parsed = HlcTimestamp.Parse(serialized);
|
||||
|
||||
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()
|
||||
{
|
||||
var original = CreateTimestamp(1704067200000, "scheduler-east-1-prod", 123);
|
||||
|
||||
var serialized = original.ToSortableString();
|
||||
var parsed = HlcTimestamp.Parse(serialized);
|
||||
|
||||
parsed.NodeId.Should().Be("scheduler-east-1-prod");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryParse_ValidString_ReturnsTrue()
|
||||
{
|
||||
var result = HlcTimestamp.TryParse("1704067200000-node1-000001", out var timestamp);
|
||||
|
||||
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)
|
||||
{
|
||||
var result = HlcTimestamp.TryParse(input, out _);
|
||||
|
||||
result.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_InvalidString_ThrowsFormatException()
|
||||
{
|
||||
var act = () => HlcTimestamp.Parse("invalid");
|
||||
|
||||
act.Should().Throw<FormatException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_Null_ThrowsArgumentNullException()
|
||||
{
|
||||
var act = () => HlcTimestamp.Parse(null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
// <copyright file="HlcTimestampTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
@@ -10,340 +9,6 @@ namespace StellaOps.HybridLogicalClock.Tests;
|
||||
/// Unit tests for <see cref="HlcTimestamp"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HlcTimestampTests
|
||||
public sealed partial 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 Default_HasExpectedValues()
|
||||
{
|
||||
// Act
|
||||
var zero = default(HlcTimestamp);
|
||||
|
||||
// Assert
|
||||
zero.PhysicalTime.Should().Be(0);
|
||||
zero.NodeId.Should().BeNull();
|
||||
zero.LogicalCounter.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ToDateTimeOffset_ConvertsCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var timestamp = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = 1704067200000, // 2024-01-01 00:00:00 UTC
|
||||
NodeId = "node1",
|
||||
LogicalCounter = 0
|
||||
};
|
||||
|
||||
// Act
|
||||
var dateTime = timestamp.ToDateTimeOffset();
|
||||
|
||||
// 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_HigherCounter_ReturnsNegative()
|
||||
{
|
||||
// Arrange
|
||||
var a = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = 1000,
|
||||
NodeId = "node1",
|
||||
LogicalCounter = 1
|
||||
};
|
||||
var b = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = 1000,
|
||||
NodeId = "node1",
|
||||
LogicalCounter = 2
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = a.CompareTo(b);
|
||||
|
||||
// Assert
|
||||
result.Should().BeLessThan(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CompareTo_DefaultTimestamp_ReturnsPositiveForNonDefault()
|
||||
{
|
||||
// Arrange
|
||||
var timestamp = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = 1000,
|
||||
NodeId = "node1",
|
||||
LogicalCounter = 1
|
||||
};
|
||||
var defaultTimestamp = default(HlcTimestamp);
|
||||
|
||||
// Act
|
||||
var result = timestamp.CompareTo(defaultTimestamp);
|
||||
|
||||
// Assert
|
||||
result.Should().BeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// <copyright file="HybridLogicalClockTests.Constructor.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
public sealed partial class HybridLogicalClockTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_NullTimeProvider_ThrowsArgumentNullException()
|
||||
{
|
||||
var act = () => new HybridLogicalClock(null!, TestNodeId, new InMemoryHlcStateStore(), _nullLogger);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("timeProvider");
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
public void Constructor_InvalidNodeId_ThrowsArgumentException(string? nodeId)
|
||||
{
|
||||
var act = () => new HybridLogicalClock(
|
||||
CreateTimeProvider(),
|
||||
nodeId!,
|
||||
new InMemoryHlcStateStore(),
|
||||
_nullLogger);
|
||||
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullStateStore_ThrowsArgumentNullException()
|
||||
{
|
||||
var act = () => new HybridLogicalClock(
|
||||
CreateTimeProvider(),
|
||||
TestNodeId,
|
||||
null!,
|
||||
_nullLogger);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("stateStore");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullLogger_ThrowsArgumentNullException()
|
||||
{
|
||||
var act = () => new HybridLogicalClock(
|
||||
CreateTimeProvider(),
|
||||
TestNodeId,
|
||||
new InMemoryHlcStateStore(),
|
||||
null!);
|
||||
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("logger");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// <copyright file="HybridLogicalClockTests.Initialize.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using System.Threading;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
public sealed partial class HybridLogicalClockTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task InitializeFromStateAsync_NoState_ReturnsFalseAsync()
|
||||
{
|
||||
var timeProvider = CreateTimeProvider(FixedTime);
|
||||
var clock = CreateClock(timeProvider);
|
||||
|
||||
var result = await clock.InitializeFromStateAsync(CancellationToken.None);
|
||||
|
||||
result.Should().BeFalse();
|
||||
clock.Current.PhysicalTime.Should().Be(0);
|
||||
clock.Current.LogicalCounter.Should().Be(0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeFromStateAsync_PersistedAhead_UsesPersistedTimeAsync()
|
||||
{
|
||||
var timeProvider = CreateTimeProvider(FixedTime);
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var persisted = CreateRemoteTimestamp(FixedTime.AddMilliseconds(10), 3, TestNodeId);
|
||||
await stateStore.SaveAsync(persisted, CancellationToken.None);
|
||||
var clock = CreateClock(timeProvider, store: stateStore);
|
||||
|
||||
var result = await clock.InitializeFromStateAsync(CancellationToken.None);
|
||||
|
||||
result.Should().BeTrue();
|
||||
clock.Current.PhysicalTime.Should().Be(persisted.PhysicalTime);
|
||||
clock.Current.LogicalCounter.Should().Be(persisted.LogicalCounter + 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InitializeFromStateAsync_PhysicalNowAhead_ResetsCounterAsync()
|
||||
{
|
||||
var physicalNow = FixedTime.AddMilliseconds(50);
|
||||
var timeProvider = CreateTimeProvider(physicalNow);
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var persisted = CreateRemoteTimestamp(FixedTime, 7, TestNodeId);
|
||||
await stateStore.SaveAsync(persisted, CancellationToken.None);
|
||||
var clock = CreateClock(timeProvider, store: stateStore);
|
||||
|
||||
var result = await clock.InitializeFromStateAsync(CancellationToken.None);
|
||||
|
||||
result.Should().BeTrue();
|
||||
clock.Current.PhysicalTime.Should().Be(physicalNow.ToUnixTimeMilliseconds());
|
||||
clock.Current.LogicalCounter.Should().Be(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// <copyright file="HybridLogicalClockTests.Receive.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
public sealed partial class HybridLogicalClockTests
|
||||
{
|
||||
[Fact]
|
||||
public void Receive_RemoteTimestampAhead_MergesCorrectly()
|
||||
{
|
||||
var timeProvider = CreateTimeProvider(FixedTime);
|
||||
var clock = CreateClock(timeProvider);
|
||||
|
||||
clock.Tick();
|
||||
var remote = CreateRemoteTimestamp(FixedTime.AddMilliseconds(100), 5);
|
||||
|
||||
var result = clock.Receive(remote);
|
||||
|
||||
result.PhysicalTime.Should().Be(remote.PhysicalTime);
|
||||
result.LogicalCounter.Should().Be(6);
|
||||
result.NodeId.Should().Be(TestNodeId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Receive_LocalTimestampAhead_MergesCorrectly()
|
||||
{
|
||||
var timeProvider = CreateTimeProvider(FixedTime);
|
||||
var clock = CreateClock(timeProvider);
|
||||
|
||||
clock.Tick();
|
||||
clock.Tick();
|
||||
var localState = clock.Tick();
|
||||
|
||||
var remote = CreateRemoteTimestamp(FixedTime.AddMilliseconds(-100), 0);
|
||||
|
||||
var result = clock.Receive(remote);
|
||||
|
||||
result.PhysicalTime.Should().Be(localState.PhysicalTime);
|
||||
result.LogicalCounter.Should().Be(localState.LogicalCounter + 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Receive_SamePhysicalTime_MergesCounters()
|
||||
{
|
||||
var timeProvider = CreateTimeProvider(FixedTime);
|
||||
var clock = CreateClock(timeProvider);
|
||||
|
||||
clock.Tick();
|
||||
clock.Tick();
|
||||
var localState = clock.Current;
|
||||
|
||||
var remote = CreateRemoteTimestamp(FixedTime, 10);
|
||||
|
||||
var result = clock.Receive(remote);
|
||||
|
||||
result.PhysicalTime.Should().Be(localState.PhysicalTime);
|
||||
result.LogicalCounter.Should().Be(11);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Receive_PhysicalClockAhead_ResetsCounter()
|
||||
{
|
||||
var timeProvider = CreateTimeProvider(FixedTime);
|
||||
var clock = CreateClock(timeProvider);
|
||||
|
||||
var remote = CreateRemoteTimestamp(FixedTime.AddMilliseconds(-1), 3);
|
||||
|
||||
var result = clock.Receive(remote);
|
||||
|
||||
result.PhysicalTime.Should().Be(FixedTime.ToUnixTimeMilliseconds());
|
||||
result.LogicalCounter.Should().Be(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// <copyright file="HybridLogicalClockTests.ReceiveSkew.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
public sealed partial class HybridLogicalClockTests
|
||||
{
|
||||
[Fact]
|
||||
public void Receive_ClockSkewExceeded_ThrowsException()
|
||||
{
|
||||
var timeProvider = CreateTimeProvider(FixedTime);
|
||||
var maxSkew = TimeSpan.FromMinutes(1);
|
||||
var clock = CreateClock(timeProvider, maxSkew: maxSkew);
|
||||
|
||||
var remote = CreateRemoteTimestamp(FixedTime.AddMinutes(2), 0);
|
||||
|
||||
var act = () => clock.Receive(remote);
|
||||
|
||||
act.Should().Throw<HlcClockSkewException>()
|
||||
.Where(e => e.MaxAllowedSkew == maxSkew)
|
||||
.Where(e => e.ActualSkew > maxSkew);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// <copyright file="HybridLogicalClockTests.State.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
public sealed partial class HybridLogicalClockTests
|
||||
{
|
||||
[Fact]
|
||||
public void Current_ReturnsLatestState()
|
||||
{
|
||||
var timeProvider = CreateTimeProvider();
|
||||
var clock = CreateClock(timeProvider);
|
||||
|
||||
var tick1 = clock.Tick();
|
||||
var current1 = clock.Current;
|
||||
|
||||
var tick2 = clock.Tick();
|
||||
var current2 = clock.Current;
|
||||
|
||||
current1.Should().Be(tick1);
|
||||
current2.Should().Be(tick2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_PersistsStateToStore()
|
||||
{
|
||||
var timeProvider = CreateTimeProvider();
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var clock = CreateClock(timeProvider, store: stateStore);
|
||||
|
||||
clock.Tick();
|
||||
|
||||
stateStore.GetAllStates().Count.Should().Be(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// <copyright file="HybridLogicalClockTests.Tick.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using System.Linq;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
public sealed partial class HybridLogicalClockTests
|
||||
{
|
||||
[Fact]
|
||||
public void Tick_Monotonic_SuccessiveTicksAlwaysIncrease()
|
||||
{
|
||||
var timeProvider = CreateTimeProvider();
|
||||
var clock = CreateClock(timeProvider);
|
||||
|
||||
var timestamps = Enumerable.Range(0, 100)
|
||||
.Select(_ => clock.Tick())
|
||||
.ToList();
|
||||
|
||||
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()
|
||||
{
|
||||
var timeProvider = CreateTimeProvider(FixedTime);
|
||||
var clock = CreateClock(timeProvider);
|
||||
|
||||
var first = clock.Tick();
|
||||
var second = clock.Tick();
|
||||
var third = clock.Tick();
|
||||
|
||||
first.LogicalCounter.Should().Be(0);
|
||||
second.LogicalCounter.Should().Be(1);
|
||||
third.LogicalCounter.Should().Be(2);
|
||||
|
||||
first.PhysicalTime.Should().Be(second.PhysicalTime);
|
||||
second.PhysicalTime.Should().Be(third.PhysicalTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_NewPhysicalTime_ResetsCounter()
|
||||
{
|
||||
var timeProvider = CreateTimeProvider(FixedTime);
|
||||
var clock = CreateClock(timeProvider);
|
||||
|
||||
clock.Tick();
|
||||
clock.Tick();
|
||||
var beforeAdvance = clock.Tick();
|
||||
|
||||
timeProvider.Advance(TimeSpan.FromMilliseconds(1));
|
||||
var afterAdvance = clock.Tick();
|
||||
|
||||
beforeAdvance.LogicalCounter.Should().Be(2);
|
||||
afterAdvance.LogicalCounter.Should().Be(0);
|
||||
afterAdvance.PhysicalTime.Should().BeGreaterThan(beforeAdvance.PhysicalTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_NodeId_IsCorrectlySet()
|
||||
{
|
||||
var timeProvider = CreateTimeProvider();
|
||||
var clock = new HybridLogicalClock(timeProvider, "my-custom-node", new InMemoryHlcStateStore(), _nullLogger);
|
||||
|
||||
var timestamp = clock.Tick();
|
||||
|
||||
timestamp.NodeId.Should().Be("my-custom-node");
|
||||
clock.NodeId.Should().Be("my-custom-node");
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
// <copyright file="HybridLogicalClockTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Time.Testing;
|
||||
using System;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
@@ -13,302 +13,33 @@ namespace StellaOps.HybridLogicalClock.Tests;
|
||||
/// Unit tests for <see cref="HybridLogicalClock"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class HybridLogicalClockTests
|
||||
public sealed partial class HybridLogicalClockTests
|
||||
{
|
||||
private const string TestNodeId = "test-node-1";
|
||||
private static readonly ILogger<HybridLogicalClock> NullLogger = NullLogger<HybridLogicalClock>.Instance;
|
||||
private static readonly DateTimeOffset FixedTime = new(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
private static readonly ILogger<HybridLogicalClock> _nullLogger = NullLogger<HybridLogicalClock>.Instance;
|
||||
|
||||
[Fact]
|
||||
public void Tick_Monotonic_SuccessiveTicksAlwaysIncrease()
|
||||
private static FakeTimeProvider CreateTimeProvider(DateTimeOffset? now = null)
|
||||
{
|
||||
// Arrange
|
||||
var timeProvider = new FakeTimeProvider(DateTimeOffset.UtcNow);
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger);
|
||||
return new FakeTimeProvider(now ?? FixedTime);
|
||||
}
|
||||
|
||||
// Act
|
||||
var timestamps = Enumerable.Range(0, 100)
|
||||
.Select(_ => clock.Tick())
|
||||
.ToList();
|
||||
private static HybridLogicalClock CreateClock(
|
||||
FakeTimeProvider timeProvider,
|
||||
InMemoryHlcStateStore? store = null,
|
||||
TimeSpan? maxSkew = null)
|
||||
{
|
||||
store ??= new InMemoryHlcStateStore();
|
||||
return new HybridLogicalClock(timeProvider, TestNodeId, store, _nullLogger, maxSkew);
|
||||
}
|
||||
|
||||
// Assert
|
||||
for (var i = 1; i < timestamps.Count; i++)
|
||||
private static HlcTimestamp CreateRemoteTimestamp(DateTimeOffset time, int counter, string nodeId = "remote-node")
|
||||
{
|
||||
return new HlcTimestamp
|
||||
{
|
||||
timestamps[i].Should().BeGreaterThan(timestamps[i - 1],
|
||||
$"Timestamp {i} should be greater than timestamp {i - 1}");
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_SamePhysicalTime_IncrementsCounter()
|
||||
{
|
||||
// Arrange
|
||||
var fixedTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FakeTimeProvider(fixedTime);
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger);
|
||||
|
||||
// Act
|
||||
var first = clock.Tick();
|
||||
var second = clock.Tick();
|
||||
var third = clock.Tick();
|
||||
|
||||
// Assert
|
||||
first.LogicalCounter.Should().Be(0);
|
||||
second.LogicalCounter.Should().Be(1);
|
||||
third.LogicalCounter.Should().Be(2);
|
||||
|
||||
// All should have same physical time
|
||||
first.PhysicalTime.Should().Be(second.PhysicalTime);
|
||||
second.PhysicalTime.Should().Be(third.PhysicalTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_NewPhysicalTime_ResetsCounter()
|
||||
{
|
||||
// Arrange
|
||||
var startTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FakeTimeProvider(startTime);
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger);
|
||||
|
||||
// Act - generate some ticks
|
||||
clock.Tick();
|
||||
clock.Tick();
|
||||
var beforeAdvance = clock.Tick();
|
||||
|
||||
// Advance time
|
||||
timeProvider.Advance(TimeSpan.FromMilliseconds(1));
|
||||
var afterAdvance = clock.Tick();
|
||||
|
||||
// Assert
|
||||
beforeAdvance.LogicalCounter.Should().Be(2);
|
||||
afterAdvance.LogicalCounter.Should().Be(0);
|
||||
afterAdvance.PhysicalTime.Should().BeGreaterThan(beforeAdvance.PhysicalTime);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_NodeId_IsCorrectlySet()
|
||||
{
|
||||
// Arrange
|
||||
var timeProvider = new FakeTimeProvider();
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var clock = new HybridLogicalClock(timeProvider, "my-custom-node", stateStore, NullLogger);
|
||||
|
||||
// Act
|
||||
var timestamp = clock.Tick();
|
||||
|
||||
// Assert
|
||||
timestamp.NodeId.Should().Be("my-custom-node");
|
||||
clock.NodeId.Should().Be("my-custom-node");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Receive_RemoteTimestampAhead_MergesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var localTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FakeTimeProvider(localTime);
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger);
|
||||
|
||||
// Local tick first
|
||||
var localTick = clock.Tick();
|
||||
|
||||
// Remote timestamp is 100ms ahead
|
||||
var remote = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = localTime.AddMilliseconds(100).ToUnixTimeMilliseconds(),
|
||||
NodeId = "remote-node",
|
||||
LogicalCounter = 5
|
||||
PhysicalTime = time.ToUnixTimeMilliseconds(),
|
||||
NodeId = nodeId,
|
||||
LogicalCounter = counter
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = clock.Receive(remote);
|
||||
|
||||
// Assert
|
||||
result.PhysicalTime.Should().Be(remote.PhysicalTime);
|
||||
result.LogicalCounter.Should().Be(6); // remote counter + 1
|
||||
result.NodeId.Should().Be(TestNodeId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Receive_LocalTimestampAhead_MergesCorrectly()
|
||||
{
|
||||
// Arrange
|
||||
var localTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FakeTimeProvider(localTime);
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger);
|
||||
|
||||
// Generate several local ticks to advance counter
|
||||
clock.Tick();
|
||||
clock.Tick();
|
||||
var localState = clock.Tick();
|
||||
|
||||
// Remote timestamp is behind
|
||||
var remote = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = localTime.AddMilliseconds(-100).ToUnixTimeMilliseconds(),
|
||||
NodeId = "remote-node",
|
||||
LogicalCounter = 0
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = clock.Receive(remote);
|
||||
|
||||
// Assert
|
||||
result.PhysicalTime.Should().Be(localState.PhysicalTime);
|
||||
result.LogicalCounter.Should().Be(localState.LogicalCounter + 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Receive_SamePhysicalTime_MergesCounters()
|
||||
{
|
||||
// Arrange
|
||||
var localTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FakeTimeProvider(localTime);
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger);
|
||||
|
||||
// Local tick
|
||||
clock.Tick();
|
||||
clock.Tick();
|
||||
var localState = clock.Current; // counter = 1
|
||||
|
||||
// Remote timestamp with same physical time but higher counter
|
||||
var remote = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = localTime.ToUnixTimeMilliseconds(),
|
||||
NodeId = "remote-node",
|
||||
LogicalCounter = 10
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = clock.Receive(remote);
|
||||
|
||||
// Assert
|
||||
result.PhysicalTime.Should().Be(localTime.ToUnixTimeMilliseconds());
|
||||
result.LogicalCounter.Should().Be(11); // max(local, remote) + 1
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Receive_ClockSkewExceeded_ThrowsException()
|
||||
{
|
||||
// Arrange
|
||||
var localTime = new DateTimeOffset(2024, 1, 1, 0, 0, 0, TimeSpan.Zero);
|
||||
var timeProvider = new FakeTimeProvider(localTime);
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var maxSkew = TimeSpan.FromMinutes(1);
|
||||
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger, maxSkew);
|
||||
|
||||
// Remote timestamp is 2 minutes ahead (exceeds 1 minute tolerance)
|
||||
var remote = new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = localTime.AddMinutes(2).ToUnixTimeMilliseconds(),
|
||||
NodeId = "remote-node",
|
||||
LogicalCounter = 0
|
||||
};
|
||||
|
||||
// Act
|
||||
var act = () => clock.Receive(remote);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<HlcClockSkewException>()
|
||||
.Where(e => e.MaxAllowedSkew == maxSkew)
|
||||
.Where(e => e.ActualSkew > maxSkew);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Current_ReturnsLatestState()
|
||||
{
|
||||
// Arrange
|
||||
var timeProvider = new FakeTimeProvider();
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger);
|
||||
|
||||
// Act
|
||||
var tick1 = clock.Tick();
|
||||
var current1 = clock.Current;
|
||||
|
||||
var tick2 = clock.Tick();
|
||||
var current2 = clock.Current;
|
||||
|
||||
// Assert
|
||||
current1.Should().Be(tick1);
|
||||
current2.Should().Be(tick2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tick_PersistsStateToStore()
|
||||
{
|
||||
// Arrange
|
||||
var timeProvider = new FakeTimeProvider();
|
||||
var stateStore = new InMemoryHlcStateStore();
|
||||
var clock = new HybridLogicalClock(timeProvider, TestNodeId, stateStore, NullLogger);
|
||||
|
||||
// Act
|
||||
clock.Tick();
|
||||
|
||||
// Assert - state should be persisted after tick
|
||||
stateStore.GetAllStates().Count.Should().Be(1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullTimeProvider_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange & Act
|
||||
var act = () => new HybridLogicalClock(null!, TestNodeId, new InMemoryHlcStateStore(), NullLogger);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<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(),
|
||||
NullLogger);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullStateStore_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange & Act
|
||||
var act = () => new HybridLogicalClock(
|
||||
new FakeTimeProvider(),
|
||||
TestNodeId,
|
||||
null!,
|
||||
NullLogger);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("stateStore");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_NullLogger_ThrowsArgumentNullException()
|
||||
{
|
||||
// Arrange & Act
|
||||
var act = () => new HybridLogicalClock(
|
||||
new FakeTimeProvider(),
|
||||
TestNodeId,
|
||||
new InMemoryHlcStateStore(),
|
||||
null!);
|
||||
|
||||
// Assert
|
||||
act.Should().Throw<ArgumentNullException>()
|
||||
.WithParameterName("logger");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// <copyright file="InMemoryHlcStateStoreTests.Load.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
public sealed partial class InMemoryHlcStateStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LoadAsync_NoState_ReturnsNullAsync()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
|
||||
var result = await store.LoadAsync("node1", CancellationToken.None);
|
||||
|
||||
result.Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_NullNodeId_ThrowsArgumentNullExceptionAsync()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
|
||||
var act = () => store.LoadAsync(null!, CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentNullException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_WhitespaceNodeId_ThrowsArgumentExceptionAsync()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
|
||||
var act = () => store.LoadAsync(" ", CancellationToken.None);
|
||||
|
||||
await act.Should().ThrowAsync<ArgumentException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_Cancelled_ThrowsOperationCanceledExceptionAsync()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var act = () => store.LoadAsync("node1", cts.Token);
|
||||
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// <copyright file="InMemoryHlcStateStoreTests.Maintenance.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using System.Threading;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
public sealed partial class InMemoryHlcStateStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SaveAsync_MultipleNodes_IsolatedAsync()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
var node1State = CreateTimestamp("node1", 1000, 1);
|
||||
var node2State = CreateTimestamp("node2", 2000, 2);
|
||||
|
||||
await store.SaveAsync(node1State, CancellationToken.None);
|
||||
await store.SaveAsync(node2State, CancellationToken.None);
|
||||
|
||||
var loaded1 = await store.LoadAsync("node1", CancellationToken.None);
|
||||
var loaded2 = await store.LoadAsync("node2", CancellationToken.None);
|
||||
|
||||
loaded1.Should().Be(node1State);
|
||||
loaded2.Should().Be(node2State);
|
||||
store.GetAllStates().Count.Should().Be(2);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Clear_RemovesAllStateAsync()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
await store.SaveAsync(CreateTimestamp("n1", 1, 0), CancellationToken.None);
|
||||
await store.SaveAsync(CreateTimestamp("n2", 2, 0), CancellationToken.None);
|
||||
|
||||
store.Clear();
|
||||
|
||||
store.GetAllStates().Count.Should().Be(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// <copyright file="InMemoryHlcStateStoreTests.Save.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
using FluentAssertions;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
|
||||
public sealed partial class InMemoryHlcStateStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SaveAsync_ThenLoadAsync_ReturnsStateAsync()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
var timestamp = CreateTimestamp("node1", 1000, 5);
|
||||
|
||||
await store.SaveAsync(timestamp, CancellationToken.None);
|
||||
var result = await store.LoadAsync("node1", CancellationToken.None);
|
||||
|
||||
result.Should().Be(timestamp);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_GreaterTimestamp_UpdatesAsync()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
var first = CreateTimestamp("node1", 1000, 5);
|
||||
var second = CreateTimestamp("node1", 1000, 10);
|
||||
|
||||
await store.SaveAsync(first, CancellationToken.None);
|
||||
await store.SaveAsync(second, CancellationToken.None);
|
||||
var result = await store.LoadAsync("node1", CancellationToken.None);
|
||||
|
||||
result.Should().Be(second);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_SmallerTimestamp_DoesNotUpdateAsync()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
var first = CreateTimestamp("node1", 1000, 10);
|
||||
var second = CreateTimestamp("node1", 1000, 5);
|
||||
|
||||
await store.SaveAsync(first, CancellationToken.None);
|
||||
await store.SaveAsync(second, CancellationToken.None);
|
||||
var result = await store.LoadAsync("node1", CancellationToken.None);
|
||||
|
||||
result.Should().Be(first);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SaveAsync_Cancelled_ThrowsOperationCanceledExceptionAsync()
|
||||
{
|
||||
var store = new InMemoryHlcStateStore();
|
||||
using var cts = new CancellationTokenSource();
|
||||
cts.Cancel();
|
||||
|
||||
var act = () => store.SaveAsync(CreateTimestamp("node1", 1000, 1), cts.Token);
|
||||
|
||||
await act.Should().ThrowAsync<OperationCanceledException>();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
// <copyright file="InMemoryHlcStateStoreTests.cs" company="StellaOps">
|
||||
// Copyright (c) StellaOps. Licensed under BUSL-1.1.
|
||||
// </copyright>
|
||||
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
namespace StellaOps.HybridLogicalClock.Tests;
|
||||
@@ -11,158 +9,15 @@ namespace StellaOps.HybridLogicalClock.Tests;
|
||||
/// Unit tests for <see cref="InMemoryHlcStateStore"/>.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class InMemoryHlcStateStoreTests
|
||||
public sealed partial class InMemoryHlcStateStoreTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LoadAsync_NoState_ReturnsNull()
|
||||
private static HlcTimestamp CreateTimestamp(string nodeId, long physicalTime, int counter)
|
||||
{
|
||||
// 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
|
||||
return new HlcTimestamp
|
||||
{
|
||||
PhysicalTime = 1000,
|
||||
NodeId = "node1",
|
||||
LogicalCounter = 5
|
||||
PhysicalTime = physicalTime,
|
||||
NodeId = nodeId,
|
||||
LogicalCounter = counter
|
||||
};
|
||||
|
||||
// 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.GetAllStates().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.GetAllStates().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>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,3 +9,4 @@ Source of truth: `docs-archived/implplan/2025-12-29-csproj-audit/SPRINT_20251229
|
||||
| AUDIT-0086-T | DONE | Revalidated 2026-01-08; open findings tracked in audit report. |
|
||||
| AUDIT-0086-A | DONE | Waived (test project; revalidated 2026-01-08). |
|
||||
| REMED-06 | DONE | SOLID review notes captured for SPRINT_20260130_002. |
|
||||
| REMED-2026-02-03 | DONE | Split test files into partials, normalized async naming, added init/state coverage, deterministic fixtures; ConfigureAwait(false) skipped in tests per xUnit1030; dotnet test passed 2026-02-03 (62 tests). |
|
||||
|
||||
Reference in New Issue
Block a user