diff --git a/src/Nonemm.Spotting/Telnet/ClusterPrompts.cs b/src/Nonemm.Spotting/Telnet/ClusterPrompts.cs index 330feaf..e35f879 100644 --- a/src/Nonemm.Spotting/Telnet/ClusterPrompts.cs +++ b/src/Nonemm.Spotting/Telnet/ClusterPrompts.cs @@ -7,7 +7,7 @@ public static class ClusterPrompts private static readonly string[] Callsign = [ "login", "log in", "logon", "enter call", "enter your call", - "your call", "callsign", "call:", + "your call", "callsign", "call:", "user:", ]; private static readonly string[] Password = ["password"]; diff --git a/src/Nonemm.Spotting/Telnet/LineAssembler.cs b/src/Nonemm.Spotting/Telnet/LineAssembler.cs index 6038d54..b8fdbb9 100644 --- a/src/Nonemm.Spotting/Telnet/LineAssembler.cs +++ b/src/Nonemm.Spotting/Telnet/LineAssembler.cs @@ -8,27 +8,39 @@ namespace Nonemm.Spotting.Telnet; public sealed class LineAssembler { private readonly StringBuilder pending = new(); + private bool afterCarriageReturn; /// What has arrived since the last line ending. public string Pending => pending.ToString(); + /// Breaks on CR, LF or CRLF. Most nodes send CRLF, but N1MM splits on CR + /// alone, which says some of them leave the LF out. public IReadOnlyList Add(string text) { List lines = []; foreach (char character in text) { - if (character == '\n') + bool wasAfterCarriageReturn = afterCarriageReturn; + afterCarriageReturn = character == '\r'; + switch (character) { - lines.Add(pending.ToString().TrimEnd('\r')); - pending.Clear(); - } - else - { - pending.Append(character); + case '\n' when wasAfterCarriageReturn: + break; + case '\n' or '\r': + lines.Add(pending.ToString()); + pending.Clear(); + break; + default: + pending.Append(character); + break; } } return lines; } - public void Clear() => pending.Clear(); + public void Clear() + { + pending.Clear(); + afterCarriageReturn = false; + } } diff --git a/tests/Nonemm.Spotting.Tests/LineAssemblerTests.cs b/tests/Nonemm.Spotting.Tests/LineAssemblerTests.cs index 632ca2f..1ce02dd 100644 --- a/tests/Nonemm.Spotting.Tests/LineAssemblerTests.cs +++ b/tests/Nonemm.Spotting.Tests/LineAssemblerTests.cs @@ -31,4 +31,31 @@ public class LineAssemblerTests Assert.Empty(assembler.Add("login: ")); Assert.Equal("login: ", assembler.Pending); } + + /// N1MM splits on CR alone, so some nodes evidently leave the LF out. + [Fact] + public void ALineEndedWithACarriageReturnAloneIsStillALine() + { + LineAssembler assembler = new(); + + Assert.Equal(["one", "two"], assembler.Add("one\rtwo\r")); + } + + [Fact] + public void CarriageReturnAndLineFeedTogetherAreOneEnding() + { + LineAssembler assembler = new(); + + Assert.Equal(["one"], assembler.Add("one\r\n")); + Assert.Equal("", assembler.Pending); + } + + [Fact] + public void AnEndingSplitAcrossTwoBlocksIsStillOneEnding() + { + LineAssembler assembler = new(); + + Assert.Equal(["one"], assembler.Add("one\r")); + Assert.Empty(assembler.Add("\n")); + } } diff --git a/tests/Nonemm.Spotting.Tests/TelnetStreamTests.cs b/tests/Nonemm.Spotting.Tests/TelnetStreamTests.cs index 4fe413b..abe8c36 100644 --- a/tests/Nonemm.Spotting.Tests/TelnetStreamTests.cs +++ b/tests/Nonemm.Spotting.Tests/TelnetStreamTests.cs @@ -38,8 +38,10 @@ public class TelnetStreamTests 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. + /// N1MM never answers the negotiation. It drops bytes 251, 252 and 255 from + /// what it displays but lets 253 and 254 through, so a node that opens with + /// IAC DO TERMINAL-TYPE puts two stray characters in its packet window and + /// leaves the node waiting for an answer that never comes. We answer. [Fact] public async Task TheNegotiationIsAnsweredAndKeptOutOfTheText() {