diff --git a/src/SIL.Harmony.Tests/Core/HybridDateTimeProviderTests.cs b/src/SIL.Harmony.Tests/Core/HybridDateTimeProviderTests.cs new file mode 100644 index 0000000..0b952f9 --- /dev/null +++ b/src/SIL.Harmony.Tests/Core/HybridDateTimeProviderTests.cs @@ -0,0 +1,101 @@ +namespace SIL.Harmony.Tests.Core; + +/// +/// Exercises the real clock logic. The rest of the suite injects a +/// MockTimeProvider that stubs this behaviour out, so without these tests the hybrid-clock guarantees +/// (monotonic timestamps when the wall clock goes backward, advancing past synced commits) have no coverage. +/// +public class HybridDateTimeProviderTests +{ + private sealed class SettableTimeProvider : TimeProvider + { + public DateTimeOffset Now { get; set; } + public override DateTimeOffset GetUtcNow() => Now; + } + + private static readonly DateTimeOffset _baseTime = new(2000, 1, 1, 0, 0, 0, TimeSpan.Zero); + + private static (HybridDateTimeProvider provider, SettableTimeProvider clock) NewProvider(HybridDateTime lastDateTime) + { + var clock = new SettableTimeProvider { Now = _baseTime }; + return (new HybridDateTimeProvider(clock, lastDateTime), clock); + } + + [Fact] + public void GetDateTime_UsesWallClockWhenItIsAheadOfLastTime() + { + var (provider, clock) = NewProvider(new HybridDateTime(_baseTime.AddHours(-1), 5)); + clock.Now = _baseTime; + + var result = provider.GetDateTime(); + + result.DateTime.Should().Be(_baseTime); + result.Counter.Should().Be(0, "a forward clock resets the counter"); + } + + [Fact] + public void GetDateTime_WhenClockGoesBackward_ReusesLastTimeAndIncrementsCounter() + { + //lastDateTime is in the future relative to the wall clock (clock was reset backwards) + var (provider, clock) = NewProvider(new HybridDateTime(_baseTime.AddHours(1), 0)); + clock.Now = _baseTime; + + var first = provider.GetDateTime(); + first.DateTime.Should().Be(_baseTime.AddHours(1), "the newer timestamp is kept, not the regressed wall clock"); + first.Counter.Should().Be(1); + + //still behind, so the counter keeps climbing to stay monotonic + var second = provider.GetDateTime(); + second.DateTime.Should().Be(_baseTime.AddHours(1)); + second.Counter.Should().Be(2); + + (second > first).Should().BeTrue(); + } + + [Fact] + public void GetDateTime_WhenClockEqualsLastTime_IncrementsCounter() + { + var (provider, clock) = NewProvider(new HybridDateTime(_baseTime, 0)); + clock.Now = _baseTime; + + var result = provider.GetDateTime(); + + result.DateTime.Should().Be(_baseTime); + result.Counter.Should().Be(1, "equal timestamps must still advance via the counter"); + } + + [Fact] + public void TakeLatestTime_AdvancesToNewestReceivedTime() + { + var (provider, clock) = NewProvider(new HybridDateTime(_baseTime, 0)); + + provider.TakeLatestTime([ + new HybridDateTime(_baseTime.AddHours(-1), 9), + new HybridDateTime(_baseTime.AddHours(2), 3), + new HybridDateTime(_baseTime.AddHours(1), 0), + ]); + + //next local write must sort after the newest received commit even though the wall clock is older + clock.Now = _baseTime; + var next = provider.GetDateTime(); + next.DateTime.Should().Be(_baseTime.AddHours(2)); + next.Counter.Should().Be(4); + } + + [Fact] + public void TakeLatestTime_IgnoresTimesOlderThanCurrent() + { + var (provider, clock) = NewProvider(new HybridDateTime(_baseTime.AddHours(5), 2)); + + provider.TakeLatestTime([ + new HybridDateTime(_baseTime, 0), + new HybridDateTime(_baseTime.AddHours(1), 0), + ]); + + //current time was newer than everything received, so it is unchanged + clock.Now = _baseTime; + var next = provider.GetDateTime(); + next.DateTime.Should().Be(_baseTime.AddHours(5)); + next.Counter.Should().Be(3); + } +} diff --git a/src/SIL.Harmony.Tests/Core/HybridDateTimeTests.cs b/src/SIL.Harmony.Tests/Core/HybridDateTimeTests.cs index cb75ae3..68477ae 100644 --- a/src/SIL.Harmony.Tests/Core/HybridDateTimeTests.cs +++ b/src/SIL.Harmony.Tests/Core/HybridDateTimeTests.cs @@ -72,4 +72,46 @@ public void CompareTo_ReturnsOneWhenThisIsGreaterThanOther() var result = dateTime.CompareTo(otherDateTime); result.Should().Be(1); } + + [Fact] + public void CompareTo_BreaksTiesByCounterWhenDateTimeIsEqual() + { + var instant = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero); + var lower = new HybridDateTime(instant, 1); + var higher = new HybridDateTime(instant, 2); + + lower.CompareTo(higher).Should().BeLessThan(0); + higher.CompareTo(lower).Should().BeGreaterThan(0); + } + + [Theory] + //same instant, ordered by counter + [InlineData("2000-01-01", 1, "2000-01-01", 2, true)] + [InlineData("2000-01-01", 2, "2000-01-01", 1, false)] + //different instant, counter is irrelevant + [InlineData("2000-01-01", 9, "2000-01-02", 0, true)] + [InlineData("2000-01-02", 0, "2000-01-01", 9, false)] + public void Operators_OrderByDateTimeThenCounter(string leftDate, long leftCounter, string rightDate, long rightCounter, bool leftIsSmaller) + { + var left = new HybridDateTime(DateTimeOffset.Parse(leftDate + "T00:00:00Z"), leftCounter); + var right = new HybridDateTime(DateTimeOffset.Parse(rightDate + "T00:00:00Z"), rightCounter); + + (left < right).Should().Be(leftIsSmaller); + (left <= right).Should().Be(leftIsSmaller); + (left > right).Should().Be(!leftIsSmaller); + (left >= right).Should().Be(!leftIsSmaller); + } + + [Fact] + public void Operators_LessOrEqualAndGreaterOrEqual_TrueWhenEqual() + { + var instant = new DateTimeOffset(2000, 1, 1, 0, 0, 0, TimeSpan.Zero); + var left = new HybridDateTime(instant, 3); + var right = new HybridDateTime(instant, 3); + + (left <= right).Should().BeTrue(); + (left >= right).Should().BeTrue(); + (left < right).Should().BeFalse(); + (left > right).Should().BeFalse(); + } } diff --git a/src/SIL.Harmony.Tests/SyncTests.cs b/src/SIL.Harmony.Tests/SyncTests.cs index a3d7d66..92ed6e6 100644 --- a/src/SIL.Harmony.Tests/SyncTests.cs +++ b/src/SIL.Harmony.Tests/SyncTests.cs @@ -149,4 +149,77 @@ public async Task CanSyncCommitsWithMoreEntitiesThanTheSqliteParameterLimit() _client1.DbContext.Set().Should().HaveCount(totalEntityCount); } + + [Fact] + public async Task ConcurrentEditsToSameEntity_ConvergeToSameValueOnBothReplicas() + { + var entityId = Guid.NewGuid(); + //both clients edit the same entity at the same instant (same DateTime, counter 0): a genuine tie + //that must be broken deterministically so both replicas pick the same winner. + var sharedDate = new DateTime(2005, 1, 1); + _client1.SetCurrentDate(sharedDate); + _client2.SetCurrentDate(sharedDate); + await _client1.WriteNextChange(_client1.SetWord(entityId, "from client1")); + await _client2.WriteNextChange(_client2.SetWord(entityId, "from client2")); + + await _client1.DataModel.SyncWith(_client2.DataModel); + + var client1Value = (await _client1.DataModel.GetLatest(entityId))!.Text; + var client2Value = (await _client2.DataModel.GetLatest(entityId))!.Text; + client1Value.Should().Be(client2Value, "concurrent edits must converge to a single deterministic winner"); + client1Value.Should().BeOneOf("from client1", "from client2"); + + var client1Snapshot = await _client1.DataModel.GetProjectSnapshot(); + var client2Snapshot = await _client2.DataModel.GetProjectSnapshot(); + client1Snapshot.LastCommitHash.Should().Be(client2Snapshot.LastCommitHash); + } + + [Fact] + public async Task ConcurrentEditsToSameEntity_LaterTimestampWins() + { + var entityId = Guid.NewGuid(); + _client1.SetCurrentDate(new DateTime(2005, 1, 1)); + _client2.SetCurrentDate(new DateTime(2005, 6, 1)); //strictly later + await _client1.WriteNextChange(_client1.SetWord(entityId, "early")); + await _client2.WriteNextChange(_client2.SetWord(entityId, "late")); + + await _client1.DataModel.SyncWith(_client2.DataModel); + + (await _client1.DataModel.GetLatest(entityId))!.Text.Should().Be("late"); + (await _client2.DataModel.GetLatest(entityId))!.Text.Should().Be("late"); + } + + [Fact] + public async Task SyncWith_CalledTwice_SecondSyncTransfersNothing() + { + await _client1.WriteNextChange(_client1.SetWord(Guid.NewGuid(), "entity1")); + await _client2.WriteNextChange(_client2.SetWord(Guid.NewGuid(), "entity2")); + + var firstSync = await _client1.DataModel.SyncWith(_client2.DataModel); + firstSync.IsSynced.Should().BeTrue(); + (firstSync.MissingFromLocal.Length + firstSync.MissingFromRemote.Length).Should().BeGreaterThan(0); + + var secondSync = await _client1.DataModel.SyncWith(_client2.DataModel); + secondSync.IsSynced.Should().BeTrue(); + secondSync.MissingFromLocal.Should().BeEmpty("both replicas are already converged"); + secondSync.MissingFromRemote.Should().BeEmpty("both replicas are already converged"); + + var client1Snapshot = await _client1.DataModel.GetProjectSnapshot(); + var client2Snapshot = await _client2.DataModel.GetProjectSnapshot(); + client1Snapshot.LastCommitHash.Should().Be(client2Snapshot.LastCommitHash); + } + + [Fact] + public async Task SyncWith_WhenRemoteShouldNotSync_ReturnsNotSyncedAndTransfersNothing() + { + await _client1.WriteNextChange(_client1.SetWord(Guid.NewGuid(), "entity1")); + var beforeSyncCount = _client1.DbContext.Commits.Count(); + + var results = await _client1.DataModel.SyncWith(NullSyncable.Instance); + + results.IsSynced.Should().BeFalse(); + results.MissingFromLocal.Should().BeEmpty(); + results.MissingFromRemote.Should().BeEmpty(); + _client1.DbContext.Commits.Count().Should().Be(beforeSyncCount, "a declined sync must not change local state"); + } }