using static DodoSSH.Client.Domain.Tests.HostFactory; namespace DodoSSH.Client.Domain.Tests; /// /// Equality of the collection types, and of the host that holds them. /// /// /// This suite guards a failure that would be invisible rather than loud. If any of these compared by /// reference, the merge would report every host as changed on every sync pass, two identical edits /// would register as a conflict, and the engine would push spurious updates forever. Nothing would /// throw and no test elsewhere would obviously fail — which is exactly why these are asserted here. /// public sealed class ValueSemanticsTests { [Fact] public void TwoHostsWithEqualContents_AreEqual() { var one = Host(jumps: [Bastion, Relay], options: [("Compression", "yes")]); var other = Host(jumps: [Bastion, Relay], options: [("Compression", "yes")]); one.ShouldBe(other); one.GetHashCode().ShouldBe(other.GetHashCode()); } [Fact] public void AHostDifferingOnlyInACollection_IsNotEqual() { Host(jumps: [Bastion]).ShouldNotBe(Host(jumps: [Relay])); Host(options: [("Compression", "yes")]).ShouldNotBe(Host(options: [("Compression", "no")])); } // Note on the assertion style below: these call Equals and the operators directly rather than // going through ShouldBe. Both of these types implement IReadOnlyList, and Shouldly compares // enumerables element by element — so ShouldBe would pass whatever Equals did, which is the one // thing this suite exists to check. [Fact] public void AJumpChain_ComparesByContentsAndOrder() { JumpChain.Create([Bastion, Relay]).Equals(JumpChain.Create([Bastion, Relay])).ShouldBeTrue(); (JumpChain.Create([Bastion, Relay]) == JumpChain.Create([Bastion, Relay])).ShouldBeTrue(); JumpChain.Create([Bastion, Relay]).Equals(JumpChain.Create([Relay, Bastion])).ShouldBeFalse(); JumpChain.Create([Bastion]).Equals(JumpChain.Create([Bastion, Relay])).ShouldBeFalse(); JumpChain.Create([]).Equals(JumpChain.Empty).ShouldBeTrue(); JumpChain.Create([Bastion]).Equals(null).ShouldBeFalse(); } [Fact] public void AJumpChain_HashesByContents() { JumpChain.Create([Bastion, Relay]).GetHashCode() .ShouldBe(JumpChain.Create([Bastion, Relay]).GetHashCode()); } [Fact] public void AJumpChain_ComparesEqualAcrossTheSpanAndSequenceFactories() { Guid[] hops = [Bastion, Relay]; JumpChain.Create(hops.AsSpan()).Equals(JumpChain.Create(hops.AsEnumerable())).ShouldBeTrue(); } [Fact] public void Directives_CompareIgnoringNameCaseAndInputOrder() { // Both halves matter. Case, because a merge picks whichever spelling it saw first and two // clients must still agree. Order, because the collection canonicalises and a user typing // the same two directives in the other order has not changed anything. var one = HostOptions.Create([new HostOption("Compression", "yes"), new HostOption("Port", "22")]); var other = HostOptions.Create([new HostOption("port", "22"), new HostOption("compression", "yes")]); one.Equals(other).ShouldBeTrue(); (one == other).ShouldBeTrue(); one.GetHashCode().ShouldBe(other.GetHashCode()); } [Fact] public void Directives_CompareValuesCaseSensitively() { // Keywords are case-insensitive in SSH; values are not. "yes" and "YES" happen to mean the // same to sshd, but this layer must not decide that for every directive that exists. HostOptions.Create([new HostOption("Compression", "yes")]) .Equals(HostOptions.Create([new HostOption("Compression", "YES")])) .ShouldBeFalse(); } [Fact] public void Directives_CompareUnequalWhenOneSideHasMore() { var one = HostOptions.Create([new HostOption("Compression", "yes")]); var other = HostOptions.Create( [new HostOption("Compression", "yes"), new HostOption("Port", "22")]); one.Equals(other).ShouldBeFalse(); HostOptions.Empty.Equals(one).ShouldBeFalse(); one.Equals(null).ShouldBeFalse(); } [Fact] public void Directives_AreHeldInNameOrder() { var options = HostOptions.Create( [ new HostOption("ServerAliveInterval", "30"), new HostOption("Compression", "yes"), ]); options[0].Name.ShouldBe("Compression"); options[1].Name.ShouldBe("ServerAliveInterval"); } [Fact] public void ARepeatedDirectiveName_IsRefused() { // A repeated keyword has no merge key, so M1 cannot represent it. Refusing is the honest // answer; silently keeping one of the two would lose data without saying so. var duplicate = new[] { new HostOption("Compression", "yes"), new HostOption("compression", "no"), }; HostOptions.TryCreate(duplicate, out _, out var error).ShouldBeFalse(); error.ShouldNotBeNull(); error.Contains("more than once", StringComparison.Ordinal).ShouldBeTrue(); Should.Throw(() => HostOptions.Create(duplicate)); } [Fact] public void ABlankDirectiveName_IsRefused() { HostOptions.TryCreate([new HostOption(" ", "x")], out _, out _).ShouldBeFalse(); } [Fact] public void AHostBuiltWithWith_KeepsCollectionEquality() { // `with` copies the collection references, so this would pass even under reference equality. // It is here because the merge builds its result with an object initialiser rather than // `with`, and both paths have to agree. var host = Host(options: [("Compression", "yes")]); var copy = host with { Notes = "changed" }; copy.Options.Equals(host.Options).ShouldBeTrue(); (copy with { Notes = host.Notes }).ShouldBe(host); } [Fact] public void TryValidate_RejectsWhatCannotBeStored() { Host(label: "").TryValidate(out _).ShouldBeFalse(); Host(hostname: " ").TryValidate(out _).ShouldBeFalse(); Host(port: 65536).TryValidate(out _).ShouldBeFalse(); Host(jumps: [Guid.Empty]).TryValidate(out _).ShouldBeFalse(); Host().TryValidate(out _).ShouldBeTrue(); } }