diff --git a/CHANGELOG.md b/CHANGELOG.md index 110228ac..a8382fe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,9 @@ and a release's notes are its section. The procedure is in [`docs/RELEASING.md`] ### Fixed +- Pueblo worlds work. The client answers a server's Pueblo greeting and switches to Pueblo markup when the + server does, and Pueblo worlds now show their ANSI colours instead of the escape codes. A world set to + `mxp` or `ansi` on F5 does not answer the greeting. - A character's on-disconnect commands are now sent, before the client closes the connection itself (Disconnect, Reconnect or quitting). They were saved and never used. - Lua scripts now run. A trigger set's script files are loaded from the `scripts` folder beside diff --git a/CLAUDE.md b/CLAUDE.md index 1d8a72b6..54f5bb78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1743,8 +1743,8 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] is parser state too: MXP has to be negotiated again on the new socket to take the session off ANSI. Left alone, a server's `ESC[6z` made the *next* connection start in secure default, and the black-on-black above survived the reconnect that is the obvious way a user tries to get rid of it. - - **`AnsiParser` and `MxpParser` hold near-duplicate escape state machines, deliberately - unconsolidated — so a fix to one has to be applied to the other, with its own test in each suite.** + - **`AnsiParser`, `MxpParser` and `PuebloParser` hold near-duplicate escape state machines, deliberately + unconsolidated — so a fix to one has to be applied to the others, with its own test in each suite.** (Same states, same CSI ranges, same OSC/DCS/ST handling; `MxpParser`'s differs only by the `z` line-tag arm and by disarming TEMP SECURE on the way out.) This was weighed and kept: extracting a shared scanner is a structural refactor, and it was not going into a fix wave that already carried @@ -1785,12 +1785,21 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] written while the mode never reverted in production, where "lands on an OPEN line" was not true of a session a server had ever secured. Closing the buffering hole itself needs its own change at the telnet layer and is out of this scope. -- **`PuebloParser` decodes no ANSI, and nothing else decodes it for it.** Its header used to say escapes - were "handled upstream" — the same false premise `MxpParser` carried, in the same words, and there is - no upstream either way: a session gets one parser. So a Pueblo world's colour arrives on screen as a - literal `[0;33m`. The behaviour clause was accurate; only the justification was invented. The fix - is the one `MxpParser` already had — its own escape state machine over `SgrCodes` — and until someone - does it this is a known gap rather than a design. +- **`PuebloParser` decodes ANSI itself, through `SgrCodes`**, for `MxpParser`'s reason: a session gets one + parser and nothing chains another in front of it. Until it did, a Pueblo world's colour arrived as a + literal `[0;33m`. Its escape machine is the third copy (see the paragraph on the duplicates above). +- **Pueblo is negotiated the way MXP is** (`PuebloProtocol`, TelnetNegotiationCore; `TelnetSession`'s + `OnPuebloOffered`/`OnPuebloEnabled`; `WorldSession.UpgradeParser`). Pueblo has no telnet option: the server + prints `This world is Pueblo 1.10 Enhanced.`, the client answers with the text line `PUEBLOCLIENT 2.50`, + and the server's `` switches the connection. Three rules: + - **The answer goes only to a server that said hello, and only from `auto` or `pueblo`** + (`TelnetSessionOptions.AnnouncePueblo`). It is a line at a login prompt, where a server without Pueblo + reads it as a name — the unsolicited-negotiation rule, in text. + - **The first markup a connection negotiates keeps it.** The parser is swapped only off the plain + `AnsiParser`, and the hello is not answered once MXP is on, so a server offering both is not flipped + between them by the order the handshakes finish in. + - **The library consumes both lines; `_pending` is cleared in both callbacks**, because `CallbackOnByteAsync` + has already collected their bytes and a prompt boundary would otherwise flush them as text. - **``/`` are answered, and the `SUPPORTS` list is held to the same rule as the MTTS bit vector: an honest claim, not an aspirational one.** The spec requires both replies to go out as a secure-tagged line (`ESC[1z` prefix, `MxpParser.SecureLinePrefix`) specifically so a server can tell diff --git a/src/SharpMUTerm.Core/Protocols/PuebloParser.cs b/src/SharpMUTerm.Core/Protocols/PuebloParser.cs index 99c5d6ec..af9e029c 100644 --- a/src/SharpMUTerm.Core/Protocols/PuebloParser.cs +++ b/src/SharpMUTerm.Core/Protocols/PuebloParser.cs @@ -22,26 +22,29 @@ namespace SharpMUTerm.Core.Protocols; /// deciding whether a world is Pueblo-enabled is a session concern handled elsewhere. /// /// -/// ANSI/VT escape sequences are not decoded, and nothing else decodes them either. An ESC -/// (0x1b) byte is passed through untouched into the span text — so a Pueblo world's colour arrives on -/// screen as a literal <ESC>[0;33m. This used to claim the escapes were "handled -/// upstream"; there is no upstream. WorldSession.CreateParser hands a session one parser -/// for its content format, never a chain, which is the same false premise -/// carried until it grew its own escape state machine (through -/// ). Doing the same here is the fix; it is a known gap -/// rather than a design, and the behaviour clause above is what actually happens today. +/// ANSI is decoded here, through , because a session has one parser and nothing +/// chains another in front of it ('s reason, in the same words). SGR changes the style; +/// every other CSI, OSC, DCS/SOS/PM/APC string and two-byte escape is consumed. The escape state machine is a +/// near-copy of 's and 's, deliberately not shared — a fix to +/// one belongs in all three, with its own test in each suite. /// /// public sealed class PuebloParser : ILineParser { private const int MaxTagLength = 4096; private const int MaxEntityLength = 32; + private const int MaxSequenceLength = 128; private enum State { Ground, Tag, Entity, + Escape, + EscapeIntermediate, + Csi, + Osc, + OscEscape, } /// A saved rendition frame, pushed when an element opens and restored when it closes. @@ -68,6 +71,7 @@ public Frame(string name, TextStyle style, SpanInteraction? interaction) private readonly List _lineSpans = new(); private readonly StringBuilder _tag = new(); private readonly StringBuilder _entity = new(); + private readonly StringBuilder _seq = new(); private readonly List _stack = new(); /// The rendition state that will apply to the next printed character. @@ -102,6 +106,13 @@ public IReadOnlyList Feed(ReadOnlySpan text) /// public StyledLine? Flush() { + // The telnet layer strips the terminator, so this is the line boundary in production, and an + // escape cannot span it — see Process. + if (IsEscapeState(_state)) + { + EndSequence(); + } + FlushRun(); CloseInteractionsAtBoundary(); if (_lineSpans.Count == 0) @@ -124,11 +135,19 @@ public void Reset() _lineSpans.Clear(); _tag.Clear(); _entity.Clear(); + _seq.Clear(); _stack.Clear(); } private void Process(char ch, ref List? lines) { + // A newline ends the line whatever sequence is in flight, or an unterminated ESC ] that a player + // typed would swallow every following line. MxpParser and AnsiParser carry the same rule. + if (ch == '\n' && IsEscapeState(_state)) + { + EndSequence(); + } + switch (_state) { case State.Ground: @@ -142,9 +161,135 @@ private void Process(char ch, ref List? lines) case State.Entity: ProcessEntity(ch, ref lines); break; + + case State.Escape: + ProcessEscape(ch); + break; + + case State.EscapeIntermediate: + // The single trailing byte of e.g. ESC ( B. + EndSequence(); + break; + + case State.Csi: + ProcessCsi(ch); + break; + + case State.Osc: + ProcessOsc(ch); + break; + + case State.OscEscape: + // ESC inside a string sequence; a following '\' is the ST terminator. + if (ch == '\\') + { + EndSequence(); + } + else + { + _state = State.Osc; + } + + break; } } + private static bool IsEscapeState(State state) => + state is State.Escape or State.EscapeIntermediate or State.Csi or State.Osc or State.OscEscape; + + private void ProcessEscape(char ch) + { + switch (ch) + { + case '[': + _seq.Clear(); + _state = State.Csi; + break; + + case ']': + case 'P': // DCS + case 'X': // SOS + case '^': // PM + case '_': // APC + // String sequences, terminated by BEL or ST — consumed so the payload never shows. + _seq.Clear(); + _state = State.Osc; + break; + + case '(': + case ')': + case '*': + case '+': + case '#': + case '%': + _state = State.EscapeIntermediate; + break; + + default: + // A two-byte escape (ESC c, ESC M, …), consumed. + EndSequence(); + break; + } + } + + private void ProcessCsi(char ch) + { + if (ch is >= '\x40' and <= '\x7e') + { + if (ch == 'm') + { + // Text so far keeps the style it was written in. + FlushRun(); + _current = SgrCodes.Apply(_current, _seq.ToString()); + } + + // Every other final byte — cursor movement, erase — means nothing to a line-oriented view. + EndSequence(); + return; + } + + if (ch is >= '\x20' and <= '\x3f') + { + if (_seq.Length < MaxSequenceLength) + { + _seq.Append(ch); + } + + return; + } + + // A control character inside the sequence aborts it as malformed. + EndSequence(); + } + + private void ProcessOsc(char ch) + { + switch (ch) + { + case '\x07': + EndSequence(); + break; + + case '\x1b': + _state = State.OscEscape; + break; + + default: + if (_seq.Length < MaxSequenceLength) + { + _seq.Append(ch); + } + + break; + } + } + + private void EndSequence() + { + _seq.Clear(); + _state = State.Ground; + } + private void ProcessGround(char ch, ref List? lines) { switch (ch) @@ -167,6 +312,11 @@ private void ProcessGround(char ch, ref List? lines) // Carriage returns are dropped; scrollback is line-based. break; + case '\x1b': + _seq.Clear(); + _state = State.Escape; + break; + default: _run.Append(ch); break; diff --git a/src/SharpMUTerm.Core/Session/WorldSession.cs b/src/SharpMUTerm.Core/Session/WorldSession.cs index 2fe275c2..435c8839 100644 --- a/src/SharpMUTerm.Core/Session/WorldSession.cs +++ b/src/SharpMUTerm.Core/Session/WorldSession.cs @@ -262,9 +262,23 @@ public async Task SendGmcpAsync(string package, string json) return true; } - private void OnMxpEnabled(object? sender, EventArgs e) + private void OnMxpEnabled(object? sender, EventArgs e) => UpgradeParser(ContentFormat.Mxp); + + /// + /// Upgrades to the Pueblo parser when the server switches the connection to Pueblo — the same rule as + /// MXP's, above: only from the automatic default. + /// + private void OnPuebloEnabled(object? sender, EventArgs e) => UpgradeParser(ContentFormat.Pueblo); + + /// + /// The one parser swap. Only from and only off the plain ANSI parser, so + /// the first markup a connection negotiates keeps it: a server that offers both MXP and Pueblo is + /// not switched back and forth by the order its handshakes finish in. (The telnet layer also declines to + /// answer a Pueblo hello once MXP is on.) + /// + private void UpgradeParser(ContentFormat format) { - if (World.ContentFormat != ContentFormat.Ansi || _parser is MxpParser) + if (World.ContentFormat != ContentFormat.Ansi || _parser is not AnsiParser) { return; } @@ -274,7 +288,7 @@ private void OnMxpEnabled(object? sender, EventArgs e) ProcessOutputLine(tail); } - _parser = NewParser(ContentFormat.Mxp); + _parser = NewParser(format); } /// @@ -456,6 +470,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) telnet.EncodingChanged += OnEncodingChanged; telnet.Disconnected += OnDisconnected; telnet.MxpEnabled += OnMxpEnabled; + telnet.PuebloEnabled += OnPuebloEnabled; try { @@ -980,6 +995,7 @@ private ITelnetSession DefaultSessionFactory(ConnectionOptions options) options: new TelnetSessionOptions { GmcpModules = World.GmcpModules.ToArray(), + AnnouncePueblo = World.ContentFormat is ContentFormat.Ansi or ContentFormat.Pueblo, CharsetOrder = order, EncodingOverride = encodingOverride, KeepaliveInterval = TelnetSessionOptions.ResolveKeepalive(World.KeepaliveSeconds), diff --git a/src/SharpMUTerm.Core/Telnet/ITelnetSession.cs b/src/SharpMUTerm.Core/Telnet/ITelnetSession.cs index b40ca485..946d415d 100644 --- a/src/SharpMUTerm.Core/Telnet/ITelnetSession.cs +++ b/src/SharpMUTerm.Core/Telnet/ITelnetSession.cs @@ -39,6 +39,12 @@ public interface ITelnetSession : IAsyncDisposable /// event EventHandler? MxpEnabled; + /// + /// Raised once the server has switched this connection to Pueblo (its start sequence, after our + /// PUEBLOCLIENT). The of Pueblo. + /// + event EventHandler? PuebloEnabled; + /// /// Raised once the client has agreed to the server's MSDP, so a consumer can ask it to REPORT the /// variables it wants. MSDP servers commonly send nothing until asked. diff --git a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs index fdbf72a7..f120fffd 100644 --- a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs +++ b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs @@ -111,6 +111,17 @@ public sealed class TelnetSessionOptions /// public IReadOnlyList GmcpModules { get; init; } = []; + /// + /// Whether to answer a server's Pueblo hello (This world is Pueblo 1.10 Enhanced.) with + /// PUEBLOCLIENT. Pueblo has no telnet option: the answer is a line of text, so it is sent only to a + /// server that has announced Pueblo, and only when nothing else (MXP) already owns the markup. The world's + /// content format decides it — see WorldSession. + /// + public bool AnnouncePueblo { get; init; } = true; + + /// The version this client announces as a Pueblo client; PennMUSH reads only the word. + public const string PuebloVersion = "2.50"; + /// /// Turns a world's configured keepalive seconds into an interval the telnet library will accept, /// or null when there should be none. @@ -372,6 +383,7 @@ private Encoding? Negotiated public event EventHandler? Disconnected; public event EventHandler? MxpEnabled; public event EventHandler? MsdpEnabled; + public event EventHandler? PuebloEnabled; public async Task ConnectAsync(CancellationToken cancellationToken = default) { @@ -384,6 +396,7 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) // (e.g. WILL NAWS) during BuildAsync, which is written straight to the transport. await _transport.ConnectAsync(cancellationToken).ConfigureAwait(false); _msdpAgreed = false; + _mxpOn = false; try { @@ -432,6 +445,7 @@ private Task BuildInterpreterAsync() .AddPlugin().OnPrompt(OnPromptAsync) .AddPlugin().OnPrompt(OnPromptAsync) .AddPlugin().OnMXPEnabled(OnMxpEnabledAsync) + .AddPlugin().OnPuebloOffered(OnPuebloOfferedAsync).OnPuebloEnabled(OnPuebloEnabledAsync) .AddPlugin().OnCompressionEnabled(OnCompressionAsync); // An idle keepalive, when the world asks for one: IAC NOP after the configured silence, so a @@ -674,8 +688,44 @@ private ValueTask OnCompressionAsync(int version, bool enabled) return ValueTask.CompletedTask; } + /// + /// The server's Pueblo hello: answered with PUEBLOCLIENT unless the world says otherwise or MXP is + /// already in use. The hello is consumed by the library, so its bytes are dropped from the pending line + /// here — they were never going to be submitted, and a prompt boundary would otherwise flush them. + /// + private async ValueTask OnPuebloOfferedAsync() + { + _pending.Clear(); + if (!_options.AnnouncePueblo || _mxpOn || _interpreter?.PluginManager?.GetPlugin() is not { } pueblo) + { + _logger.LogInformation("The server offered Pueblo; not announcing (content format or MXP)."); + return; + } + + try + { + await pueblo.AnnounceAsync(TelnetSessionOptions.PuebloVersion).ConfigureAwait(false); + } + catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException) + { + _logger.LogWarning(ex, "Could not answer the server's Pueblo hello."); + } + } + + /// The server's Pueblo start sequence, consumed by the library like the hello. + private ValueTask OnPuebloEnabledAsync(PuebloClient client) + { + _pending.Clear(); + _logger.LogInformation("Pueblo negotiated."); + PuebloEnabled?.Invoke(this, EventArgs.Empty); + return ValueTask.CompletedTask; + } + + private volatile bool _mxpOn; + private ValueTask OnMxpEnabledAsync() { + _mxpOn = true; _logger.LogInformation("MXP negotiated."); MxpEnabled?.Invoke(this, EventArgs.Empty); return ValueTask.CompletedTask; diff --git a/tests/SharpMUTerm.Core.Tests/Protocols/PuebloParserTests.cs b/tests/SharpMUTerm.Core.Tests/Protocols/PuebloParserTests.cs index 3e20d4c1..e64e2124 100644 --- a/tests/SharpMUTerm.Core.Tests/Protocols/PuebloParserTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Protocols/PuebloParserTests.cs @@ -293,11 +293,53 @@ public async Task StrayLessThan_WithoutClose_IsEmittedLiterally() await Assert.That(line.Text).IsEqualTo("love <3"); } + /// + /// ANSI is decoded here, because nothing in front of this parser decodes it: a Pueblo world's colour used + /// to arrive as a literal <ESC>[33m. + /// [Test] - public async Task Esc_IsPassedThroughUntouched() + public async Task Ansi_SgrSetsTheStyleAndLeavesNoEscapeInTheText() { - var line = ParseSingleLine("a\x1bb\n"); - await Assert.That(line.Text).IsEqualTo("a\x1bb"); + var line = ParseSingleLine("\x1b[33mYellow\x1b[0m plain\n"); + + await Assert.That(line.Text).IsEqualTo("Yellow plain"); + await Assert.That(line.Spans[0].Style.Foreground).IsEqualTo(TerminalColor.FromIndex(3)); + await Assert.That(line.Spans[^1].Style.Foreground).IsEqualTo(TerminalColor.Default); + } + + [Test] + public async Task Ansi_AndPuebloMarkupComposeOnOneSpan() + { + var line = ParseSingleLine("\x1b[31mred bold\n"); + + await Assert.That(line.Spans[0].Style.Foreground).IsEqualTo(TerminalColor.FromIndex(1)); + await Assert.That(line.Spans[0].Style.Attributes.HasFlag(TextAttributes.Bold)).IsTrue(); + } + + [Test] + public async Task Ansi_OtherSequencesAreConsumed() + { + // Cursor movement, an OSC title (BEL- and ST-terminated), a charset select and a two-byte escape. + var line = ParseSingleLine("a\x1b[2Kb\x1b]0;title\ac\x1b]2;t\x1b\\d\x1b(Be\x1bMf\n"); + + await Assert.That(line.Text).IsEqualTo("abcdef"); + } + + /// + /// An unterminated string escape a player typed ends with the line, in production's shape: fed without + /// its newline (the telnet layer strips it), then flushed. + /// + [Test] + public async Task Ansi_AnUnterminatedOscEndsWithTheLine() + { + var parser = new PuebloParser(); + parser.Feed("before\x1b]0;never closed"); + var first = parser.Flush(); + parser.Feed("next line"); + var second = parser.Flush(); + + await Assert.That(first!.Text).IsEqualTo("before"); + await Assert.That(second!.Text).IsEqualTo("next line"); } [Test] diff --git a/tests/SharpMUTerm.Core.Tests/Session/FakeTelnetSession.cs b/tests/SharpMUTerm.Core.Tests/Session/FakeTelnetSession.cs index 6424962b..df28c582 100644 --- a/tests/SharpMUTerm.Core.Tests/Session/FakeTelnetSession.cs +++ b/tests/SharpMUTerm.Core.Tests/Session/FakeTelnetSession.cs @@ -23,6 +23,7 @@ internal sealed class FakeTelnetSession : ITelnetSession public event EventHandler? EncodingChanged; public event EventHandler? Disconnected; public event EventHandler? MxpEnabled; + public event EventHandler? PuebloEnabled; public event EventHandler? MsdpEnabled; public List<(string Variable, string Value)> SentMsdp { get; } = new(); @@ -88,6 +89,8 @@ public void EmitEncoding(SessionEncoding encoding) public void RaiseMxpEnabled() => MxpEnabled?.Invoke(this, EventArgs.Empty); + public void RaisePuebloEnabled() => PuebloEnabled?.Invoke(this, EventArgs.Empty); + public void RaiseMsdpEnabled() => MsdpEnabled?.Invoke(this, EventArgs.Empty); public void EmitMsdp(string json) => MsdpReceived?.Invoke(this, new MsdpMessageEventArgs(json)); diff --git a/tests/SharpMUTerm.Core.Tests/Session/WorldSessionSettingsTests.cs b/tests/SharpMUTerm.Core.Tests/Session/WorldSessionSettingsTests.cs index d7e954c2..9157a08c 100644 --- a/tests/SharpMUTerm.Core.Tests/Session/WorldSessionSettingsTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Session/WorldSessionSettingsTests.cs @@ -86,4 +86,53 @@ public async Task EmojiSwitchesChangedBetweenConnectionsApplyToTheNextOne() await Assert.That(lines.Any(l => l.Text == "one :fire:")).IsTrue(); await Assert.That(lines.Any(l => l.Text.StartsWith("two", StringComparison.Ordinal) && l.Text.Contains("🔥", StringComparison.Ordinal))).IsTrue(); } + [Test] + public async Task PuebloNegotiationSwitchesAnAutomaticWorldsParser() + { + var telnet = new FakeTelnetSession(); + var session = new WorldSession(World(), sessionFactory: _ => telnet); + await session.ConnectAsync(); + + telnet.EmitLine("before"); + telnet.RaisePuebloEnabled(); + telnet.EmitLine("after"); + + var lines = session.Scrollback.Snapshot(); + await Assert.That(lines.Any(l => l.Text == "before")).IsTrue(); + await Assert.That(lines.Any(l => l.Text == "after")).IsTrue(); + } + + /// + /// The first markup a connection negotiates keeps it: MXP after Pueblo leaves the Pueblo parser in place. + /// SEND tells them apart — a fresh MXP parser refuses it on an open line and echoes it, Pueblo + /// strips it. + /// + [Test] + public async Task MxpAfterPuebloDoesNotSwitchAgain() + { + var telnet = new FakeTelnetSession(); + var session = new WorldSession(World(), sessionFactory: _ => telnet); + await session.ConnectAsync(); + + telnet.RaisePuebloEnabled(); + telnet.RaiseMxpEnabled(); + telnet.EmitLine("after"); + + await Assert.That(session.Scrollback.Snapshot().Any(l => l.Text == "after")).IsTrue(); + } + + [Test] + public async Task AnExplicitMxpWorldIgnoresPueblo() + { + var world = World(); + world.ContentFormat = ContentFormat.Mxp; + var telnet = new FakeTelnetSession(); + var session = new WorldSession(world, sessionFactory: _ => telnet); + await session.ConnectAsync(); + + telnet.RaisePuebloEnabled(); + telnet.EmitLine("after"); + + await Assert.That(session.Scrollback.Snapshot().Any(l => l.Text == "after")).IsFalse(); + } } diff --git a/tests/SharpMUTerm.Core.Tests/Telnet/PuebloHandshakeTests.cs b/tests/SharpMUTerm.Core.Tests/Telnet/PuebloHandshakeTests.cs new file mode 100644 index 00000000..d7865a73 --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Telnet/PuebloHandshakeTests.cs @@ -0,0 +1,73 @@ +using System.Text; +using Microsoft.Extensions.Logging.Abstractions; +using SharpMUTerm.Core.Telnet; + +namespace SharpMUTerm.Core.Tests.Telnet; + +/// +/// The Pueblo handshake from the client's side, as PennMUSH frames it: the server's hello is answered with +/// PUEBLOCLIENT, the server's start sequence switches the connection, and neither line reaches the +/// output. Pueblo is text at a login prompt, so nothing is said to a server that did not say hello first. +/// +public class PuebloHandshakeTests +{ + private const string Hello = "This world is Pueblo 1.10 Enhanced.\r\n"; + private const string Start = "\n"; + + private static byte[] Ascii(string text) => Encoding.ASCII.GetBytes(text); + + [Test] + public async Task TheHelloIsAnsweredAndTheStartSwitchesTheConnection() + { + var transport = new ScriptedTransport { Greeting = Ascii(Hello + "Welcome\r\n") }; + await using var session = new TelnetSession(transport, NullLogger.Instance); + var lines = new List(); + var enabled = 0; + session.OutputReceived += (_, e) => { lock (lines) lines.Add(e.Text); }; + session.PuebloEnabled += (_, _) => Interlocked.Increment(ref enabled); + await session.ConnectAsync(); + + await Assert.That(await transport.WaitForSentAsync(Ascii("PUEBLOCLIENT 2.50\r\n"))).IsTrue(); + + transport.SendToClient(Ascii(Start + "bold\r\n")); + for (var i = 0; i < 100 && Volatile.Read(ref enabled) == 0; i++) + { + await Task.Delay(10); + } + + await Assert.That(Volatile.Read(ref enabled)).IsEqualTo(1); + await Task.Delay(50); + string[] seen; + lock (lines) + { + seen = [.. lines]; + } + + await Assert.That(seen).Contains("Welcome"); + await Assert.That(seen).Contains("bold"); + await Assert.That(seen.Any(l => l.Contains("Pueblo", StringComparison.Ordinal) || l.Contains("xch_", StringComparison.Ordinal))).IsFalse(); + } + + [Test] + public async Task ASilentServerHearsNothing() + { + var transport = new ScriptedTransport { Greeting = Ascii("Welcome\r\n") }; + await using var session = new TelnetSession(transport, NullLogger.Instance); + await session.ConnectAsync(); + await Task.Delay(150); + + await Assert.That(Encoding.ASCII.GetString(transport.Sent)).DoesNotContain("PUEBLOCLIENT"); + } + + [Test] + public async Task AWorldThatSaysNoDoesNotAnswer() + { + var transport = new ScriptedTransport { Greeting = Ascii(Hello) }; + await using var session = new TelnetSession( + transport, NullLogger.Instance, new TelnetSessionOptions { AnnouncePueblo = false }); + await session.ConnectAsync(); + await Task.Delay(150); + + await Assert.That(Encoding.ASCII.GetString(transport.Sent)).DoesNotContain("PUEBLOCLIENT"); + } +} diff --git a/tests/SharpMUTerm.Scripting.Tests/FakeTelnetSession.cs b/tests/SharpMUTerm.Scripting.Tests/FakeTelnetSession.cs index efc455f7..dc37fcd6 100644 --- a/tests/SharpMUTerm.Scripting.Tests/FakeTelnetSession.cs +++ b/tests/SharpMUTerm.Scripting.Tests/FakeTelnetSession.cs @@ -23,6 +23,7 @@ internal sealed class FakeTelnetSession : ITelnetSession public event EventHandler? EncodingChanged; public event EventHandler? Disconnected; public event EventHandler? MxpEnabled; + public event EventHandler? PuebloEnabled; public event EventHandler? MsdpEnabled; public List<(string Variable, string Value)> SentMsdp { get; } = new(); @@ -88,6 +89,8 @@ public void EmitEncoding(SessionEncoding encoding) public void RaiseMxpEnabled() => MxpEnabled?.Invoke(this, EventArgs.Empty); + public void RaisePuebloEnabled() => PuebloEnabled?.Invoke(this, EventArgs.Empty); + public void RaiseMsdpEnabled() => MsdpEnabled?.Invoke(this, EventArgs.Empty); public void EmitMsdp(string json) => MsdpReceived?.Invoke(this, new MsdpMessageEventArgs(json)); diff --git a/tests/SharpMUTerm.Tui.Tests/RecordingTelnetSession.cs b/tests/SharpMUTerm.Tui.Tests/RecordingTelnetSession.cs index 8f592dfc..ab4d200b 100644 --- a/tests/SharpMUTerm.Tui.Tests/RecordingTelnetSession.cs +++ b/tests/SharpMUTerm.Tui.Tests/RecordingTelnetSession.cs @@ -65,6 +65,7 @@ public IReadOnlyList Lines #pragma warning disable CS0067 // Required by the interface; these tests drive sizes and output, not these. public event EventHandler? MsspReceived; public event EventHandler? MxpEnabled; + public event EventHandler? PuebloEnabled; #pragma warning restore CS0067 public event EventHandler? MsdpEnabled;