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");
+ }
}