Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions src/SIL.Harmony.Tests/Core/HybridDateTimeProviderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
namespace SIL.Harmony.Tests.Core;

/// <summary>
/// Exercises the real <see cref="HybridDateTimeProvider"/> 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.
/// </summary>
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);
}
}
42 changes: 42 additions & 0 deletions src/SIL.Harmony.Tests/Core/HybridDateTimeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
73 changes: 73 additions & 0 deletions src/SIL.Harmony.Tests/SyncTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -149,4 +149,77 @@ public async Task CanSyncCommitsWithMoreEntitiesThanTheSqliteParameterLimit()

_client1.DbContext.Set<Word>().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<Word>(entityId))!.Text;
var client2Value = (await _client2.DataModel.GetLatest<Word>(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<Word>(entityId))!.Text.Should().Be("late");
(await _client2.DataModel.GetLatest<Word>(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");
}
}
Loading