namespace DodoSSH.Client.Domain.Tests; /// /// The merge primitives. /// /// /// These are the rules the whole sync story rests on, so they are tested as rules rather than /// through the sync engine: every triple of (ancestor, local, remote) states is enumerated, and each /// asserts both the surviving value and — where a side lost — that the losing value came back. /// public sealed class ThreeWayMergeTests { // ---- Scalar ---- [Fact] public void NeitherSideChanged_IsAgreement() { var merge = ThreeWayMerge.Scalar("base", "base", "base"); merge.Value.ShouldBe("base"); merge.Decision.ShouldBe(MergeDecision.Agreed); merge.IsConflicted.ShouldBeFalse(); } [Fact] public void OnlyLocalChanged_KeepsTheLocalValue() { var merge = ThreeWayMerge.Scalar("base", "mine", "base"); merge.Value.ShouldBe("mine"); merge.Decision.ShouldBe(MergeDecision.TookLocal); } [Fact] public void OnlyRemoteChanged_KeepsTheRemoteValue() { var merge = ThreeWayMerge.Scalar("base", "base", "theirs"); merge.Value.ShouldBe("theirs"); merge.Decision.ShouldBe(MergeDecision.TookRemote); } [Fact] public void BothSidesMadeTheSameChange_IsAgreementRatherThanAConflict() { // Two people fixing the same typo must not be asked to arbitrate. var merge = ThreeWayMerge.Scalar("base", "fixed", "fixed"); merge.Value.ShouldBe("fixed"); merge.Decision.ShouldBe(MergeDecision.Agreed); merge.IsConflicted.ShouldBeFalse(); } [Fact] public void BothSidesChangedDifferently_TakesRemoteAndReportsLocal() { // Remote wins so that every replica resolves the same triple identically; without a fixed // winner two clients each keep their own value and push over each other forever. var merge = ThreeWayMerge.Scalar("base", "mine", "theirs"); merge.Value.ShouldBe("theirs"); merge.Decision.ShouldBe(MergeDecision.Conflicted); merge.IsConflicted.ShouldBeTrue(); // The whole justification for picking a side: the other one is handed back, never dropped. merge.Discarded.ShouldBe("mine"); } [Fact] public void AConflictedMerge_IsIdempotentOnceResolved() { // Convergence, spelled out. Having taken the remote value, re-merging against the same // remote must be agreement rather than a fresh conflict — otherwise the two clients // ping-pong. var first = ThreeWayMerge.Scalar("base", "mine", "theirs"); var second = ThreeWayMerge.Scalar("theirs", first.Value, "theirs"); second.Decision.ShouldBe(MergeDecision.Agreed); second.Value.ShouldBe("theirs"); } [Fact] public void Scalar_UsesTheSuppliedComparer() { // Ordinal by default would call these a conflict; the comparer is how a field opts out. var merge = ThreeWayMerge.Scalar("base", "SAME", "same", StringComparer.OrdinalIgnoreCase); merge.Decision.ShouldBe(MergeDecision.Agreed); } [Fact] public void Scalar_HandlesNullOnAnySide() { // Nullable fields are the common case — Username and Notes are both optional — so a null // must be an ordinary value here rather than a special case that throws. ThreeWayMerge.Scalar(null, "set", null).Value.ShouldBe("set"); ThreeWayMerge.Scalar("was", null, "was").Value.ShouldBeNull(); ThreeWayMerge.Scalar(null, null, null).Decision.ShouldBe(MergeDecision.Agreed); } // ---- Map ---- [Fact] public void EachSideAddedADifferentKey_KeepsBoth() { // The single most visible benefit of a per-key merge over comparing whole collections: two // people adding different directives to one host both keep theirs. var merge = Map( ancestor: [], local: [("Compression", "yes")], remote: [("ServerAliveInterval", "30")]); merge.Merged.Count.ShouldBe(2); merge.Merged["Compression"].ShouldBe("yes"); merge.Merged["ServerAliveInterval"].ShouldBe("30"); merge.Conflicts.ShouldBeEmpty(); } [Fact] public void EachSideAddedTheSameKeyDifferently_TakesRemoteAndReportsLocal() { var merge = Map( ancestor: [], local: [("Port", "2222")], remote: [("Port", "2200")]); merge.Merged["Port"].ShouldBe("2200"); var conflict = merge.Conflicts.ShouldHaveSingleItem(); conflict.Key.ShouldBe("Port"); conflict.Kept.ShouldBe("2200"); conflict.Discarded.ShouldBe("2222"); conflict.DiscardedSide.ShouldBe(MergeSide.Local); conflict.DiscardedWasRemoval.ShouldBeFalse(); } [Fact] public void OneSideRemovedAKeyTheOtherLeftAlone_RemovesIt() { Map( ancestor: [("Compression", "yes")], local: [], remote: [("Compression", "yes")]) .Merged.ShouldBeEmpty(); Map( ancestor: [("Compression", "yes")], local: [("Compression", "yes")], remote: []) .Merged.ShouldBeEmpty(); } [Fact] public void RemoteEditedAKeyLocalRemoved_KeepsTheEditAndReportsTheRemoval() { // An edit outlives a removal in both directions. Re-applying a removal costs one click; // a discarded value may be the only copy of something the user cannot reconstruct. var merge = Map( ancestor: [("Compression", "yes")], local: [], remote: [("Compression", "no")]); merge.Merged["Compression"].ShouldBe("no"); var conflict = merge.Conflicts.ShouldHaveSingleItem(); conflict.DiscardedSide.ShouldBe(MergeSide.Local); conflict.DiscardedWasRemoval.ShouldBeTrue(); conflict.Kept.ShouldBe("no"); } [Fact] public void LocalEditedAKeyRemoteRemoved_KeepsTheEditAndReportsTheRemoval() { var merge = Map( ancestor: [("Compression", "yes")], local: [("Compression", "no")], remote: []); merge.Merged["Compression"].ShouldBe("no"); var conflict = merge.Conflicts.ShouldHaveSingleItem(); // The overridden side is the remote one here, which is what makes this asymmetric from the // scalar rule: the tie-break is "a value beats an absence" before it is "remote wins". conflict.DiscardedSide.ShouldBe(MergeSide.Remote); conflict.DiscardedWasRemoval.ShouldBeTrue(); } [Fact] public void BothSidesRemovedTheSameKey_IsAgreement() { var merge = Map( ancestor: [("Compression", "yes")], local: [], remote: []); merge.Merged.ShouldBeEmpty(); merge.Conflicts.ShouldBeEmpty(); } [Fact] public void UnchangedKeys_SurviveAlongsideConflictingOnes() { // A conflict on one key must not disturb its neighbours, which is the difference between // field-level merge and replacing the collection. var merge = Map( ancestor: [("Keep", "same"), ("Fight", "base")], local: [("Keep", "same"), ("Fight", "mine")], remote: [("Keep", "same"), ("Fight", "theirs")]); merge.Merged["Keep"].ShouldBe("same"); merge.Merged["Fight"].ShouldBe("theirs"); merge.Conflicts.ShouldHaveSingleItem().Key.ShouldBe("Fight"); } [Fact] public void Map_TreatsKeysUnderTheSuppliedComparer() { // SSH keywords are case-insensitive. Treating these as two keys would let a host carry // both Compression and compression, which no client could then reconcile. var merge = Map( ancestor: [("Compression", "yes")], local: [("compression", "yes")], remote: [("COMPRESSION", "yes")]); merge.Merged.Count.ShouldBe(1); merge.Conflicts.ShouldBeEmpty(); } [Fact] public void Map_NeverDropsAValueWithoutReportingIt() { // The invariant, asserted directly rather than inferred from the cases above: every value // present on either side either survives into the merge or appears in the conflict list. var local = new[] { ("A", "1"), ("B", "2"), ("C", "3") }; var remote = new[] { ("A", "9"), ("B", "2"), ("D", "4") }; var merge = Map(ancestor: [("A", "0"), ("B", "2")], local: local, remote: remote); foreach (var (key, value) in local.Concat(remote)) { var survived = merge.Merged.TryGetValue(key, out var kept) && string.Equals(kept, value, StringComparison.Ordinal); var reported = merge.Conflicts.Any(c => HostOption.NameComparer.Equals(c.Key, key) && string.Equals(c.Discarded, value, StringComparison.Ordinal)); (survived || reported).ShouldBeTrue($"{key}={value} was neither kept nor reported."); } } private static MapMerge Map( (string Key, string Value)[] ancestor, (string Key, string Value)[] local, (string Key, string Value)[] remote) => ThreeWayMerge.Map( ToMap(ancestor), ToMap(local), ToMap(remote), HostOption.NameComparer, StringComparer.Ordinal); private static Dictionary ToMap((string Key, string Value)[] entries) { var map = new Dictionary(HostOption.NameComparer); foreach (var (key, value) in entries) { map[key] = value; } return map; } }