Speak telnet properly to the cluster node
The old client read the socket with a StreamReader, so the option negotiation a node sends on connect landed in the text as control bytes and made a mess of the first lines. TelnetStream now answers it: it agrees to ECHO and SUPPRESS-GO-AHEAD, refuses everything else, swallows subnegotiations, and tracks what it already answered so the two ends do not reply to each other for ever. A command split across two reads is still understood, because a socket hands over whatever has turned up rather than whole messages. LineAssembler keeps the tail that has no line ending yet. Nodes write their login prompt as "login: " with nothing after it, so the old ReadLineAsync sat waiting for a line that never came and only got through because the greeting happened to mention a matching word. Login now follows N1MM: it looks for LOGON, ENTER CALL, LOG IN and the rest, and sends the callsign anyway after ten seconds if no prompt turns up. A password is sent when the node asks for one and the operator configured one. The commands go out after that, not straight after the call. A connection that has heard nothing for four minutes gets a blank line so the node does not drop it as idle. The old prompt check matched "call" anywhere in a line, which any spot comment could trigger. SendSpotAsync sends the node's dx command. Alt+P, or Edit / Spot It, spots the call being typed at the current frequency, or the last contact logged when nothing is typed, and puts it on our own bandmap without waiting for it to come back round from the node. The spot parser now takes a line with no colon after the spotter, and finds the time when DXSpider has put the spotter's grid after it. The cluster tests run over a real socket against a node fake that sends the negotiation, the prompt and spot lines. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
126
tests/Nonemm.Spotting.Tests/ClusterClientTests.cs
Normal file
126
tests/Nonemm.Spotting.Tests/ClusterClientTests.cs
Normal file
@@ -0,0 +1,126 @@
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Text;
|
||||
|
||||
namespace Nonemm.Spotting.Tests;
|
||||
|
||||
/// The client driven through a real socket, so the telnet negotiation, the
|
||||
/// login and the spot parsing are exercised together.
|
||||
public class ClusterClientTests : IDisposable
|
||||
{
|
||||
private const byte Iac = 255;
|
||||
private const byte Wont = 252;
|
||||
private const byte Do = 253;
|
||||
private const byte TerminalType = 24;
|
||||
|
||||
private static readonly TimeSpan Patience = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly TcpListener listener;
|
||||
|
||||
public ClusterClientTests()
|
||||
{
|
||||
listener = new TcpListener(IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
}
|
||||
|
||||
private int Port => ((IPEndPoint)listener.LocalEndpoint).Port;
|
||||
|
||||
public void Dispose() => listener.Stop();
|
||||
|
||||
private ClusterClient Connect(IReadOnlyList<string>? commands = null) =>
|
||||
new("127.0.0.1", Port, "DL1ABC", commands, retryInterval: TimeSpan.FromMinutes(1));
|
||||
|
||||
private static async Task<byte[]> ReadAsync(NetworkStream stream, int bytes)
|
||||
{
|
||||
byte[] buffer = new byte[bytes];
|
||||
int read = 0;
|
||||
while (read < bytes)
|
||||
{
|
||||
int got = await stream.ReadAsync(buffer.AsMemory(read));
|
||||
if (got == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
read += got;
|
||||
}
|
||||
return buffer[..read];
|
||||
}
|
||||
|
||||
private static async Task<string> ReadTextAsync(NetworkStream stream, int bytes) =>
|
||||
Encoding.Latin1.GetString(await ReadAsync(stream, bytes));
|
||||
|
||||
[Fact]
|
||||
public async Task TheCallsignGoesOutWhenTheNodeAsksForIt()
|
||||
{
|
||||
using ClusterClient cluster = Connect();
|
||||
cluster.Start();
|
||||
using TcpClient node = await listener.AcceptTcpClientAsync().WaitAsync(Patience);
|
||||
NetworkStream stream = node.GetStream();
|
||||
|
||||
await stream.WriteAsync(new byte[] { Iac, Do, TerminalType });
|
||||
await stream.WriteAsync(Encoding.Latin1.GetBytes("login: "));
|
||||
|
||||
byte[] sent = await ReadAsync(stream, 3 + 8).WaitAsync(Patience);
|
||||
|
||||
Assert.Equal(new byte[] { Iac, Wont, TerminalType }, sent[..3]);
|
||||
Assert.Equal("DL1ABC\r\n", Encoding.Latin1.GetString(sent[3..]));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheCommandsGoOutAfterTheLogin()
|
||||
{
|
||||
using ClusterClient cluster = Connect(["set/skimmer"]);
|
||||
cluster.Start();
|
||||
using TcpClient node = await listener.AcceptTcpClientAsync().WaitAsync(Patience);
|
||||
NetworkStream stream = node.GetStream();
|
||||
|
||||
await stream.WriteAsync(Encoding.Latin1.GetBytes("Please enter your call: "));
|
||||
|
||||
Assert.Equal(
|
||||
"DL1ABC\r\nset/skimmer\r\n",
|
||||
await ReadTextAsync(stream, 8 + 13).WaitAsync(Patience));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASpotFromTheNodeReachesTheBandmap()
|
||||
{
|
||||
using ClusterClient cluster = Connect();
|
||||
TaskCompletionSource<Spot> arrived = new();
|
||||
cluster.SpotArrived += (_, spot) => arrived.TrySetResult(spot);
|
||||
cluster.Start();
|
||||
using TcpClient node = await listener.AcceptTcpClientAsync().WaitAsync(Patience);
|
||||
|
||||
await node.GetStream().WriteAsync(Encoding.Latin1.GetBytes(
|
||||
"DX de W3LPL: 14025.0 JA1XYZ CQ 1234Z\r\n"));
|
||||
|
||||
Spot spot = await arrived.Task.WaitAsync(Patience);
|
||||
Assert.Equal("JA1XYZ", spot.Call.Text);
|
||||
Assert.Equal(14_025_000, spot.Frequency.Hertz);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SpottingAStationSendsTheNodesDxCommand()
|
||||
{
|
||||
using ClusterClient cluster = Connect();
|
||||
TaskCompletionSource connected = new();
|
||||
cluster.ConnectionChanged += (_, up) =>
|
||||
{
|
||||
if (up)
|
||||
{
|
||||
connected.TrySetResult();
|
||||
}
|
||||
};
|
||||
cluster.Start();
|
||||
using TcpClient node = await listener.AcceptTcpClientAsync().WaitAsync(Patience);
|
||||
await connected.Task.WaitAsync(Patience);
|
||||
|
||||
await cluster.SendSpotAsync(
|
||||
Core.Frequency.FromKilohertz(14_025),
|
||||
Core.Callsign.Parse("JA1XYZ"),
|
||||
"CQ");
|
||||
|
||||
Assert.Equal(
|
||||
"dx 14025.0 JA1XYZ CQ\r\n",
|
||||
await ReadTextAsync(node.GetStream(), 22).WaitAsync(Patience));
|
||||
}
|
||||
}
|
||||
24
tests/Nonemm.Spotting.Tests/ClusterPromptsTests.cs
Normal file
24
tests/Nonemm.Spotting.Tests/ClusterPromptsTests.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using Nonemm.Spotting.Telnet;
|
||||
|
||||
namespace Nonemm.Spotting.Tests;
|
||||
|
||||
public class ClusterPromptsTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("login: ")]
|
||||
[InlineData("Please enter your call: ")]
|
||||
[InlineData("Hello, please log in")]
|
||||
[InlineData("Enter Callsign:")]
|
||||
public void ANodeAskingForTheCallsignIsRecognised(string text) =>
|
||||
Assert.True(ClusterPrompts.AsksForCallsign(text));
|
||||
|
||||
[Theory]
|
||||
[InlineData("DX de W3LPL: 14025.0 JA1XYZ")]
|
||||
[InlineData("")]
|
||||
public void OrdinaryTrafficIsNotALoginPrompt(string text) =>
|
||||
Assert.False(ClusterPrompts.AsksForCallsign(text));
|
||||
|
||||
[Fact]
|
||||
public void APasswordPromptIsRecognised() =>
|
||||
Assert.True(ClusterPrompts.AsksForPassword("password: "));
|
||||
}
|
||||
50
tests/Nonemm.Spotting.Tests/FakeTelnetStream.cs
Normal file
50
tests/Nonemm.Spotting.Tests/FakeTelnetStream.cs
Normal file
@@ -0,0 +1,50 @@
|
||||
namespace Nonemm.Spotting.Tests;
|
||||
|
||||
/// A stream that hands out bytes a test prepared and keeps whatever is written
|
||||
/// back, so the telnet negotiation can be checked without a socket. Each read
|
||||
/// returns one prepared block, the way a socket hands over one packet.
|
||||
public sealed class FakeTelnetStream : Stream
|
||||
{
|
||||
private readonly Queue<byte[]> blocks;
|
||||
private readonly List<byte> written = [];
|
||||
|
||||
public FakeTelnetStream(params byte[][] blocks) => this.blocks = new Queue<byte[]>(blocks);
|
||||
|
||||
public IReadOnlyList<byte> Written => written;
|
||||
|
||||
public override bool CanRead => true;
|
||||
|
||||
public override bool CanSeek => false;
|
||||
|
||||
public override bool CanWrite => true;
|
||||
|
||||
public override long Length => throw new NotSupportedException();
|
||||
|
||||
public override long Position
|
||||
{
|
||||
get => throw new NotSupportedException();
|
||||
set => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public override int Read(byte[] buffer, int offset, int count)
|
||||
{
|
||||
if (blocks.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
byte[] block = blocks.Dequeue();
|
||||
block.CopyTo(buffer, offset);
|
||||
return block.Length;
|
||||
}
|
||||
|
||||
public override void Write(byte[] buffer, int offset, int count) =>
|
||||
written.AddRange(buffer.Skip(offset).Take(count));
|
||||
|
||||
public override void Flush()
|
||||
{
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
|
||||
|
||||
public override void SetLength(long value) => throw new NotSupportedException();
|
||||
}
|
||||
34
tests/Nonemm.Spotting.Tests/LineAssemblerTests.cs
Normal file
34
tests/Nonemm.Spotting.Tests/LineAssemblerTests.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
using Nonemm.Spotting.Telnet;
|
||||
|
||||
namespace Nonemm.Spotting.Tests;
|
||||
|
||||
public class LineAssemblerTests
|
||||
{
|
||||
[Fact]
|
||||
public void CompleteLinesComeOutWithoutTheirEndings()
|
||||
{
|
||||
LineAssembler assembler = new();
|
||||
|
||||
Assert.Equal(["one", "two"], assembler.Add("one\r\ntwo\r\n"));
|
||||
Assert.Equal("", assembler.Pending);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ALineSplitOverTwoBlocksIsPutBackTogether()
|
||||
{
|
||||
LineAssembler assembler = new();
|
||||
|
||||
Assert.Empty(assembler.Add("DX de W3"));
|
||||
Assert.Equal(["DX de W3LPL"], assembler.Add("LPL\r\n"));
|
||||
}
|
||||
|
||||
/// The login prompt has no line ending, so it only ever shows up here.
|
||||
[Fact]
|
||||
public void TextWithNoLineEndingWaitsInPending()
|
||||
{
|
||||
LineAssembler assembler = new();
|
||||
|
||||
Assert.Empty(assembler.Add("login: "));
|
||||
Assert.Equal("login: ", assembler.Pending);
|
||||
}
|
||||
}
|
||||
@@ -31,4 +31,39 @@ public class SpotLineTests
|
||||
Spot? spot = SpotLine.Parse("DX de W3LPL: 14025.0 DL1ABC CQ 2350Z", Now);
|
||||
Assert.Equal(new DateTime(2026, 5, 29, 23, 50, 0, DateTimeKind.Utc), spot?.AtUtc);
|
||||
}
|
||||
|
||||
/// DXSpider puts the spotter's grid after the time.
|
||||
[Fact]
|
||||
public void TheTimeIsFoundEvenWithSomethingAfterIt()
|
||||
{
|
||||
Spot? spot = SpotLine.Parse(
|
||||
"DX de OK1ABC-#: 7005.0 DL1ABC CQ 1234Z JO70",
|
||||
Now);
|
||||
|
||||
Assert.Equal(new DateTime(2026, 5, 30, 12, 34, 0, DateTimeKind.Utc), spot?.AtUtc);
|
||||
Assert.Equal("CQ", spot?.Comment);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TheSpotterKeepsTheNodeSuffix() =>
|
||||
Assert.Equal("OK1ABC-#", SpotLine.Parse("DX de OK1ABC-#: 7005.0 DL1ABC CQ 1234Z", Now)?.Spotter);
|
||||
|
||||
/// Not every node writes the colon after the spotter.
|
||||
[Fact]
|
||||
public void ASpotWithNoColonAfterTheSpotterStillReads()
|
||||
{
|
||||
Spot? spot = SpotLine.Parse("DX de W3LPL 14025.0 DL1ABC CQ 1234Z", Now);
|
||||
|
||||
Assert.Equal("W3LPL", spot?.Spotter);
|
||||
Assert.Equal("DL1ABC", spot?.Call.Text);
|
||||
Assert.Equal(14_025_000, spot?.Frequency.Hertz);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ASpotWithNoTimeIsTimedOnArrival() =>
|
||||
Assert.Equal(Now, SpotLine.Parse("DX de W3LPL: 14025.0 DL1ABC CQ", Now)?.AtUtc);
|
||||
|
||||
[Fact]
|
||||
public void ALineWithNoFrequencyIsNotASpot() =>
|
||||
Assert.Null(SpotLine.Parse("DX de W3LPL: hello there", Now));
|
||||
}
|
||||
|
||||
135
tests/Nonemm.Spotting.Tests/TelnetStreamTests.cs
Normal file
135
tests/Nonemm.Spotting.Tests/TelnetStreamTests.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using System.Text;
|
||||
using Nonemm.Spotting.Telnet;
|
||||
|
||||
namespace Nonemm.Spotting.Tests;
|
||||
|
||||
public class TelnetStreamTests
|
||||
{
|
||||
private const byte Iac = 255;
|
||||
private const byte SubnegotiationEnd = 240;
|
||||
private const byte SubnegotiationBegin = 250;
|
||||
private const byte Will = 251;
|
||||
private const byte Wont = 252;
|
||||
private const byte Do = 253;
|
||||
private const byte Dont = 254;
|
||||
private const byte Echo = 1;
|
||||
private const byte SuppressGoAhead = 3;
|
||||
private const byte TerminalType = 24;
|
||||
|
||||
private static byte[] Text(string text) => Encoding.Latin1.GetBytes(text);
|
||||
|
||||
private static async Task<(string Read, IReadOnlyList<byte> Written)> Run(params byte[][] blocks)
|
||||
{
|
||||
FakeTelnetStream fake = new(blocks);
|
||||
TelnetStream telnet = new(fake);
|
||||
StringBuilder read = new();
|
||||
while (await telnet.ReadAsync() is { } text)
|
||||
{
|
||||
read.Append(text);
|
||||
}
|
||||
return (read.ToString(), fake.Written);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlainTextComesBackUnchanged()
|
||||
{
|
||||
(string read, _) = await Run(Text("DX de W3LPL: 14025.0 JA1XYZ\r\n"));
|
||||
|
||||
Assert.Equal("DX de W3LPL: 14025.0 JA1XYZ\r\n", read);
|
||||
}
|
||||
|
||||
/// A client that leaves the negotiation unanswered gets these bytes mixed
|
||||
/// into the first line it reads.
|
||||
[Fact]
|
||||
public async Task TheNegotiationIsAnsweredAndKeptOutOfTheText()
|
||||
{
|
||||
(string read, IReadOnlyList<byte> written) = await Run(
|
||||
[Iac, Do, TerminalType, .. Text("login: ")]);
|
||||
|
||||
Assert.Equal("login: ", read);
|
||||
Assert.Equal([Iac, Wont, TerminalType], written);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnOptionWeSupportIsAgreedTo()
|
||||
{
|
||||
(_, IReadOnlyList<byte> written) = await Run([Iac, Will, SuppressGoAhead]);
|
||||
|
||||
Assert.Equal([Iac, Do, SuppressGoAhead], written);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnOptionWeDoNotSupportIsRefused()
|
||||
{
|
||||
(_, IReadOnlyList<byte> written) = await Run([Iac, Will, TerminalType]);
|
||||
|
||||
Assert.Equal([Iac, Dont, TerminalType], written);
|
||||
}
|
||||
|
||||
/// Answering an option that is already on would have the two ends replying
|
||||
/// to each other for as long as the connection lasts.
|
||||
[Fact]
|
||||
public async Task TheSameOptionIsNotAgreedToTwice()
|
||||
{
|
||||
(_, IReadOnlyList<byte> written) = await Run(
|
||||
[Iac, Will, Echo],
|
||||
[Iac, Will, Echo]);
|
||||
|
||||
Assert.Equal([Iac, Do, Echo], written);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AnUnwantedOptionIsNotRefusedAgainAfterItIsOff()
|
||||
{
|
||||
(_, IReadOnlyList<byte> written) = await Run([Iac, Wont, Echo]);
|
||||
|
||||
Assert.Empty(written);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ASubnegotiationIsSwallowedWhole()
|
||||
{
|
||||
(string read, _) = await Run(
|
||||
[.. Text("a"), Iac, SubnegotiationBegin, TerminalType, 1, 2, 3, Iac, SubnegotiationEnd, .. Text("b")]);
|
||||
|
||||
Assert.Equal("ab", read);
|
||||
}
|
||||
|
||||
/// A command can arrive split over two reads, because a socket hands over
|
||||
/// whatever has turned up rather than whole messages.
|
||||
[Fact]
|
||||
public async Task ACommandSplitAcrossTwoBlocksIsStillUnderstood()
|
||||
{
|
||||
(string read, IReadOnlyList<byte> written) = await Run(
|
||||
[.. Text("ab"), Iac],
|
||||
[Do, TerminalType, .. Text("cd")]);
|
||||
|
||||
Assert.Equal("abcd", read);
|
||||
Assert.Equal([Iac, Wont, TerminalType], written);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ADoubledIacIsOneByteOfText()
|
||||
{
|
||||
(string read, _) = await Run([.. Text("a"), Iac, Iac, .. Text("b")]);
|
||||
|
||||
Assert.Equal("aÿb", read);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TheNullOfABareCarriageReturnIsDropped()
|
||||
{
|
||||
(string read, _) = await Run([.. Text("a\r"), 0, .. Text("b")]);
|
||||
|
||||
Assert.Equal("a\rb", read);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ALineGoesOutWithTheEndingTelnetAsksFor()
|
||||
{
|
||||
FakeTelnetStream fake = new();
|
||||
await new TelnetStream(fake).WriteLineAsync("dx 14025.0 JA1XYZ");
|
||||
|
||||
Assert.Equal(Text("dx 14025.0 JA1XYZ\r\n"), fake.Written);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user