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(sshKeyId: Guid.Empty).TryValidate(out _).ShouldBeFalse();
Host(credentialId: Guid.Empty).TryValidate(out _).ShouldBeFalse();
Host().TryValidate(out _).ShouldBeTrue();
}
[Fact]
public void AHostAuthenticatesOneWay_NotTwo()
{
// SSH would happily try a key and fall back to a password, and a host that named both would leave
// "how does this authenticate?" without a single answer — so the interface, the connect path and the
// user would each be free to guess differently. Refused at the type instead.
var both = Host(sshKeyId: DeployKey, credentialId: Guid.CreateVersion7());
both.TryValidate(out var reason).ShouldBeFalse();
reason.ShouldNotBeNull().ShouldContain("not both");
Host(sshKeyId: DeployKey).TryValidate(out _).ShouldBeTrue();
Host(credentialId: Guid.CreateVersion7()).TryValidate(out _).ShouldBeTrue();
}
}