diff --git a/CLAUDE.md b/CLAUDE.md index 3941b1a1..3351bc22 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -520,6 +520,29 @@ fallbacks) for inline images/maps. - **A highlight rule needs no route to reach the pane a capture rule sent the line to.** There is one line and one set of destinations, and every matched rule's highlight is on it. Do not "fix" that into a per-rule delivery. +- **A prompt reaches the pane again, and everywhere a line reaches** (`WorldSession.OnOutputReceived`, + Core; `fix(prompt)`, `cd06e8b`). `22bd97f`'s header/status-bar rewrite deleted the one render that ever + read `CurrentPrompt`, so a prompt was parsed and stored but shown nowhere. **A prompt is now an ordinary + line through `ProcessOutputLine`**, the same function every other line of output runs through, so it + reaches scrollback and the spill, `PlainTextLogSink`/`HtmlLogSink`, `RestoreLog`, `OutputSearch`, the + trigger/alias/macro engines, and the unread/activity/away bars, exactly as a line the server terminated + with its own newline would. **On a MUD that prompts after every command, this is one extra line per + command** — the cost of the fix, not a defect in it; Mudlet does the same, and it is what + "searchable and trigger-visible" requires. Two decisions not to relitigate. + **It lands exactly once.** `ProcessOutputLine` is handed the pre-emoji `raw` line, not the + already-substituted one built for `CurrentPrompt`/`PromptChanged` — handing it the substituted line + would run emoji substitution twice. **A prompt boundary with no printable run prints no line.** A burst + that ends in bare SGR (`ESC[0m`) or an erase sequence and then goes quiet for the hold buffers nothing in + `AnsiParser`, so `Flush()` returns null; `CurrentPrompt`/`PromptChanged` still update, but + `ProcessOutputLine` is not called — that is a boundary, not a blank line the server sent, and is the + opposite case from `fix(line)`'s genuinely blank line, which does print. Getting this backwards means a + blank line lands in every buffer once per prompt, forever. + - **The line carries `StyledLine.IsPrompt`**, set once at the end of `ProcessOutputLine` on the line + actually delivered — not threaded through `StripColour`/`ExpandTabs`/the trigger engine/emoji + substitution. It travels through `LinePrinted` and `SpawnLineEventArgs` for free, since both already + carry a `StyledLine`, and round-trips through `StyledLineCodec` (a previously-unused flag bit, so no + `RestoreLog` format-version bump). **Wired, not yet acted on**: nothing today gags, logs, restores, + searches or badges a prompt any differently from an ordinary line because of this bit. ## Building and testing @@ -1278,7 +1301,27 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] permitted transitions from `StartNegotiation`; before it each one hit `OnUnhandledTriggerAsync`, logging Critical and recovering through `Trigger.Error`, which on some interleavings ate the sequence behind it. **2.10.0** carries the raw bytes of each MSSP value beside the decoded - string. **2.11.0** is the prompt-marker fix below. + string. **2.11.0** is the prompt-marker fix below. **2.12.0** is the one that actually motivated + a further jump: it ships **`PacketPatchProtocol`**, included in `AddDefaultMUDProtocols` + automatically (an appended, defaulted `packetPatchHoldTime` parameter this codebase doesn't set) + and infers a prompt boundary from 500ms of silence for servers that mark none at all. + `IProtocolContext` is unchanged in 2.12.0: the prompt-boundary members + (`TakePartialLineAsPrompt`, `HasPartialLine`, `HasSeenMarkedPrompt`) are public on + `TelnetInterpreter`, reachable through the interface's existing `Interpreter` property, so there + is no public break to migrate for. The + `CallbackOnByteAsync` property this file reaches by reflection is unchanged across the whole jump: + still `public Func? CallbackOnByteAsync { get; init; }` on + `TelnetInterpreter`. + - **`cd06e8b` (the prompt render) and `04e0e26` (this 2.12.0 bump) must not be separated.** Reverting + the render alone while 2.12.0 stays pinned sends every packet-patch-inferred prompt fragment to + `CurrentPrompt`, which nothing renders — the text is simply gone, silently. Before 2.12.0 that same + fragment was merely glued to the head of the next line, which reads as a cosmetic wrap issue rather + than lost text. Revert both together or neither. + - **Never pin a version that is not on nuget.org, and never work around it with a local feed.** A + pin that only resolves from a local `nuget.config` builds here and fails `NU1101` everywhere else; + if a locally-built nupkg of that version is already in `~/.nuget/packages/`, the real package + publishing later fails `NU1403` (content mismatch) until that cache directory is deleted. Delete + the stale cache; do not commit a `nuget.config` pointing at `/tmp`. - **A prompt ends with `IAC EOR` or `IAC GA`, and until 2.11.0 this client could only see the first of them.** `TelnetSession.OnPromptAsync` is the only thing that flushes `_pending` — the unterminated line `CallbackOnByteAsync` accumulates — so a server whose prompt boundary the library @@ -1446,11 +1489,14 @@ markup (`[bold #rrggbb on #rrggbb]…[/]`, `[[`/`]]` escaping, `[link=url]…[/] 2066 settles on — and naming one is an *override*: still offered at the head of the order so a cooperative server agrees, but used regardless of what it says. Four things about this library will bite you, and all four already have: - - **`TelnetInterpreter.CurrentEncoding` defaults to `Encoding.ASCII`**, and that default is not inert: - it is handed to `CallbackOnByteAsync`/`CallbackOnSubmitAsync` for every byte and used for GMCP, MSDP - and everything we send. On a server that never negotiates CHARSET — most MU\* servers — every - byte above 0x7F became `?`. `TelnetSession` seeds that property (reflectively, `internal set`, the - same way `CharsetProtocol` itself writes it) with the head of the stated order. + - **`TelnetInterpreter.CurrentEncoding` defaults to `Encoding.UTF8`** (`TelnetStandardInterpreter.cs:46`), + and that default is not something to rely on unseeded: it is handed to + `CallbackOnByteAsync`/`CallbackOnSubmitAsync` for every byte and used for GMCP, MSDP and everything + we send, before this session's own encoding decision (an override, or the head of `CharsetOrder`) + has had any say. `TelnetSession` seeds that property (reflectively, `internal set`, the same way + `CharsetProtocol` itself writes it) with the head of the stated order — otherwise a world pinned to + a non-UTF-8 override, or a server that never negotiates CHARSET at all, would decode against + whichever encoding the library happens to default to rather than what this session decided. **MSSP fields were decoded as `Encoding.ASCII` through 2.6.x** (fixed in 2.7.0, pinned by `MsspParsingTests`); treat non-ASCII in an MSSP field from an older library as unrecoverable. Two consequences worth knowing from the pre-fix era: the plaintext `MSSP-REQUEST` fallback went diff --git a/Directory.Packages.props b/Directory.Packages.props index 0a0e38d0..261ccd62 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,7 +10,7 @@ - + diff --git a/src/SharpMUTerm.Core/Session/WorldSession.cs b/src/SharpMUTerm.Core/Session/WorldSession.cs index 54273ac0..8faa309c 100644 --- a/src/SharpMUTerm.Core/Session/WorldSession.cs +++ b/src/SharpMUTerm.Core/Session/WorldSession.cs @@ -406,10 +406,25 @@ private void OnOutputReceived(object? sender, TelnetOutputEventArgs e) if (e.IsPrompt) { _parser.Feed(e.Text); - var raw = _parser.Flush() ?? StyledLine.Empty; - var prompt = ApplyEmoji(_text?.StripIncomingColour == true ? StyledText.StripColour(raw) : raw); + var flushed = _parser.Flush(); + var raw = flushed ?? StyledLine.Empty; + var prompt = ApplyEmoji(_text?.StripIncomingColour == true ? StyledText.StripColour(raw) : raw) + .WithPrompt(true); CurrentPrompt = prompt; PromptChanged?.Invoke(this, prompt); + + // Uses raw, not prompt: ProcessOutputLine runs StripIncomingColour/ExpandTabs/triggers/ + // ApplyEmoji itself, so the already-substituted line would double-substitute. + // + // Only when flushed is not null. A prompt boundary with no printable run (a bare SGR + // reset, an erase sequence) makes Flush() return null — that is a boundary, not a blank + // line the server sent, unlike the deliberate ProcessOutputLine(StyledLine.Empty) call + // below. Do not merge the two. + if (flushed is not null) + { + ProcessOutputLine(raw, isPrompt: true); + } + return; } @@ -442,7 +457,11 @@ private void OnOutputReceived(object? sender, TelnetOutputEventArgs e) 0, TextSettings.MaxTabWidth); - private void ProcessOutputLine(StyledLine line) + /// + /// is not threaded through the pipeline below (colour-strip, tab + /// expand, triggers, emoji) — it is applied once, at the end, to the line actually delivered. + /// + private void ProcessOutputLine(StyledLine line, bool isPrompt = false) { // Colour is stripped from what the *server* sent, before the triggers run: a highlight rule // and this client's own system/echo lines are not "incoming ANSI colour" and keep theirs. @@ -470,7 +489,7 @@ private void ProcessOutputLine(StyledLine line) // rather than before it, so a link's target is exactly the text under it — a span whose visible // text and destination disagree is the shape of a phishing link, and this client should not be // in the business of manufacturing one. - var shown = ApplyLinks(ApplyEmoji(result.Line)); + var shown = ApplyLinks(ApplyEmoji(result.Line)).WithPrompt(isPrompt); foreach (var target in result.SpawnTargets) { diff --git a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs index d198feae..cd289a25 100644 --- a/src/SharpMUTerm.Core/Telnet/TelnetSession.cs +++ b/src/SharpMUTerm.Core/Telnet/TelnetSession.cs @@ -242,12 +242,14 @@ public sealed class TelnetSession : ITelnetSession // TelnetInterpreter.CurrentEncoding has an internal setter, which CharsetProtocol itself writes // through reflection once negotiation settles. We seed it the same way at connect time, for two - // reasons. It *defaults to Encoding.ASCII*, and that default is not inert: it is handed to - // CallbackOnByteAsync/CallbackOnSubmitAsync for every byte, and used to decode GMCP, MSDP and MSSP - // payloads and to encode everything we send — so before negotiation, or on the many MU* servers - // that never implement RFC 2066 at all, every byte above 0x7F became '?'. And because the seed is - // an instance nothing else can produce, "has CHARSET settled?" becomes an exact reference - // comparison rather than a guess about a value that might legitimately be ASCII. + // reasons. It *defaults to Encoding.UTF8* (TelnetStandardInterpreter.cs:46), and that default is + // not inert: it is handed to CallbackOnByteAsync once per byte and to CallbackOnSubmitAsync once + // per submitted line, and used to decode GMCP, MSDP and MSSP payloads and to encode everything we + // send — so before negotiation, a + // world pinned to a non-UTF-8 override, or a server that never implements RFC 2066 at all, would + // decode against UTF-8 regardless of what this session decided. And because the seed is an + // instance nothing else can produce, "has CHARSET settled?" becomes an exact reference comparison + // rather than a guess about a value that might legitimately be UTF-8. private static readonly PropertyInfo? InterpreterEncodingProperty = typeof(TelnetInterpreter).GetProperty(nameof(TelnetInterpreter.CurrentEncoding)) is { CanWrite: true } p ? p @@ -291,8 +293,10 @@ public TelnetSession(ITransport transport, ILogger? logger = null, TelnetSession /// /// Precedence is override, then negotiation, then the head of the stated preference order. The /// last arm is what a server that never speaks CHARSET lands on: the app's first preference, - /// normally UTF-8. It is emphatically not the interpreter's own Encoding.ASCII - /// default, which is what used to reach the decode path and mangle every non-ASCII byte. + /// normally UTF-8. It is emphatically not the interpreter's own unseeded + /// Encoding.UTF8 default (TelnetStandardInterpreter.cs:46) reached before this + /// session's own decision — an override to a non-UTF-8 charset, or the head of a non-default + /// — has had any say. /// /// public SessionEncoding CurrentEncoding @@ -423,17 +427,23 @@ private Task BuildInterpreterAsync() } /// - /// Replaces the interpreter's default with what this session would - /// otherwise assume, so nothing decodes as ASCII merely because negotiation hasn't happened yet — - /// including the GMCP/MSDP/MSSP payloads and outbound bytes the library encodes for itself. The - /// library overwrites this the moment CHARSET settles. + /// Replaces the interpreter's unseeded default + /// (TelnetStandardInterpreter.cs:46) with what this session would otherwise assume, so + /// nothing decodes against that default merely because negotiation hasn't happened yet — + /// including the GMCP/MSDP/MSSP payloads and outbound bytes the library encodes for itself. A + /// world pinned to a non-UTF-8 override is exactly the case this protects: without seeding, its + /// bytes would decode as UTF-8 until CHARSET settles or forever on a server that never negotiates + /// it. The library overwrites this the moment CHARSET settles. /// private void SeedInterpreterEncoding(TelnetInterpreter interpreter) { if (InterpreterEncodingProperty is null) { _logger.LogWarning( - "TelnetInterpreter.CurrentEncoding is not writable; pre-negotiation payloads will decode as ASCII."); + "TelnetInterpreter.CurrentEncoding is not writable; pre-negotiation GMCP/MSDP/MSSP payloads and " + + "outbound bytes the library encodes for itself will decode/encode against its unseeded UTF-8 " + + "default regardless of this session's own encoding decision. Ordinary output lines are " + + "unaffected — OnSubmitAsync decodes those with this session's own CurrentEncoding.Encoding."); return; } diff --git a/src/SharpMUTerm.Core/Text/StyledLine.cs b/src/SharpMUTerm.Core/Text/StyledLine.cs index eb89d4fd..a73d66cd 100644 --- a/src/SharpMUTerm.Core/Text/StyledLine.cs +++ b/src/SharpMUTerm.Core/Text/StyledLine.cs @@ -11,11 +11,12 @@ public sealed class StyledLine private readonly StyledSpan[] _spans; private string? _text; - public StyledLine(IEnumerable spans, TerminalColor? ruleColor = null) + public StyledLine(IEnumerable spans, TerminalColor? ruleColor = null, bool isPrompt = false) { ArgumentNullException.ThrowIfNull(spans); _spans = spans.Where(s => s.Length > 0).ToArray(); RuleColor = ruleColor; + IsPrompt = isPrompt; } /// @@ -24,8 +25,26 @@ public StyledLine(IEnumerable spans, TerminalColor? ruleColor = null /// public TerminalColor? RuleColor { get; } + /// + /// True when this line is a prompt — a telnet IAC GA/EOR boundary, or + /// TelnetNegotiationCore's packet-patch silence heuristic, rather than a line the server + /// terminated with its own newline (). + /// + /// Set once, at the end of WorldSession.ProcessOutputLine, on the line actually delivered + /// to LinePrinted/SpawnLine — not threaded through StripColour, + /// ExpandTabs, the trigger engine or emoji substitution, none of which are asked to + /// preserve it. A consumer can tell a prompt from an ordinary line; as of this flag's + /// introduction nothing yet treats one differently (what gags, logs, restores, searches or + /// badges is unchanged). + /// + /// + public bool IsPrompt { get; } + /// Returns a copy of this line carrying the given trigger-highlight rule colour. - public StyledLine WithRule(TerminalColor color) => new(_spans, color); + public StyledLine WithRule(TerminalColor color) => new(_spans, color, IsPrompt); + + /// Returns a copy of this line with set to . + public StyledLine WithPrompt(bool isPrompt) => isPrompt == IsPrompt ? this : new(_spans, RuleColor, isPrompt); /// An empty line (a blank row of output). public static StyledLine Empty { get; } = new(Array.Empty()); diff --git a/src/SharpMUTerm.Core/Text/StyledLineCodec.cs b/src/SharpMUTerm.Core/Text/StyledLineCodec.cs index 0588877a..55b7133f 100644 --- a/src/SharpMUTerm.Core/Text/StyledLineCodec.cs +++ b/src/SharpMUTerm.Core/Text/StyledLineCodec.cs @@ -18,7 +18,7 @@ namespace SharpMUTerm.Core.Text; /// 7-bit-length-prefixed UTF-8, so text needs no escaping and may contain any character including /// control bytes and newlines): /// -/// byte flags bit 0: a RuleColor follows +/// byte flags bit 0: a RuleColor follows; bit 1: line.IsPrompt /// colour ruleColor (present only when bit 0 is set) /// 7bit spanCount /// spanCount times: @@ -44,6 +44,14 @@ public static class StyledLineCodec { private const byte FlagHasRuleColor = 1 << 0; + /// + /// A previously-unused bit added for . Backward-compatible by + /// construction, so this did not need a RestoreLog format-version bump: a record written + /// before this bit existed always has it clear, and reading that as "not a prompt" is simply + /// correct — that data genuinely predates the concept, not a value being reinterpreted. + /// + private const byte FlagIsPrompt = 1 << 1; + /// /// The encoding used for every string field. Replacement (not exception) fallback, so an /// unpaired surrogate — which nothing in the inbound pipeline can produce, since text is decoded @@ -57,7 +65,7 @@ public static void Write(BinaryWriter writer, StyledLine line) ArgumentNullException.ThrowIfNull(writer); ArgumentNullException.ThrowIfNull(line); - var flags = (byte)(line.RuleColor is null ? 0 : FlagHasRuleColor); + var flags = (byte)((line.RuleColor is null ? 0 : FlagHasRuleColor) | (line.IsPrompt ? FlagIsPrompt : 0)); writer.Write(flags); if (line.RuleColor is { } rule) { @@ -102,6 +110,7 @@ public static StyledLine Read(BinaryReader reader) var flags = reader.ReadByte(); TerminalColor? rule = (flags & FlagHasRuleColor) != 0 ? ReadColor(reader) : null; + var isPrompt = (flags & FlagIsPrompt) != 0; var spanCount = reader.Read7BitEncodedInt(); if (spanCount < 0) @@ -111,7 +120,8 @@ public static StyledLine Read(BinaryReader reader) if (spanCount == 0) { - return rule is null ? StyledLine.Empty : StyledLine.Empty.WithRule(rule.Value); + var empty = rule is null ? StyledLine.Empty : StyledLine.Empty.WithRule(rule.Value); + return empty.WithPrompt(isPrompt); } var spans = new StyledSpan[spanCount]; @@ -136,7 +146,7 @@ public static StyledLine Read(BinaryReader reader) spans[i] = new StyledSpan(text, style, interaction); } - return new StyledLine(spans, rule); + return new StyledLine(spans, rule, isPrompt); } /// Encodes one line's payload to a fresh array (convenience for tests and callers off the hot path). diff --git a/tests/SharpMUTerm.Core.Tests/Session/WorldSessionPromptTests.cs b/tests/SharpMUTerm.Core.Tests/Session/WorldSessionPromptTests.cs new file mode 100644 index 00000000..34aa712b --- /dev/null +++ b/tests/SharpMUTerm.Core.Tests/Session/WorldSessionPromptTests.cs @@ -0,0 +1,106 @@ +using SharpMUTerm.Core.Automation; +using SharpMUTerm.Core.Configuration; +using SharpMUTerm.Core.Session; +using SharpMUTerm.Core.Text; + +namespace SharpMUTerm.Core.Tests.Session; + +public class WorldSessionPromptTests +{ + private static (WorldSession session, FakeTelnetSession telnet) Create( + WorldDefinition world, + TriggerSet? set = null) + { + var telnet = new FakeTelnetSession(); + var sets = set is null ? null : new[] { set }; + var session = new WorldSession(world, triggerSets: sets, sessionFactory: _ => telnet); + return (session, telnet); + } + + private static WorldDefinition World() => new() { Name = "T", Host = "h", Port = 1, LocalEcho = true }; + + [Test] + public async Task APromptIsAddedToTheSessionsScrollbackAsWellAsCurrentPrompt() + { + var (session, telnet) = Create(World()); + await session.ConnectAsync(); + + telnet.EmitPrompt("HP:100>"); + + await Assert.That(session.CurrentPrompt).IsNotNull(); + await Assert.That(session.CurrentPrompt!.Text).IsEqualTo("HP:100>"); + await Assert.That(session.Scrollback.Snapshot().Any(l => l.Text == "HP:100>")).IsTrue(); + } + + [Test] + public async Task APromptRunsThroughTheTriggersLikeAnyOtherLine() + { + var set = new TriggerSet(); + set.Triggers.Add(new Trigger { Pattern = @"^HP:", Actions = new TriggerActions { SpawnTarget = "Vitals" } }); + var (session, telnet) = Create(World(), set); + SpawnLineEventArgs? spawned = null; + session.SpawnLine += (_, e) => spawned = e; + await session.ConnectAsync(); + + telnet.EmitPrompt("HP:100>"); + + await Assert.That(spawned).IsNotNull(); + await Assert.That(spawned!.Target).IsEqualTo("Vitals"); + await Assert.That(spawned!.Line.Text).IsEqualTo("HP:100>"); + } + + [Test] + public async Task APromptIsFlaggedAsAPromptAndAnOrdinaryLineIsNot() + { + var (session, telnet) = Create(World()); + StyledLine? printed = null; + session.LinePrinted += (_, l) => printed = l; + await session.ConnectAsync(); + + telnet.EmitLine("You see a troll."); + await Assert.That(printed).IsNotNull(); + await Assert.That(printed!.IsPrompt).IsFalse(); + + printed = null; + telnet.EmitPrompt("HP:100>"); + + await Assert.That(printed).IsNotNull(); + await Assert.That(printed!.IsPrompt).IsTrue(); + await Assert.That(session.CurrentPrompt!.IsPrompt).IsTrue(); + } + + [Test] + public async Task AStyleOnlyPromptUpdatesCurrentPromptButPrintsNoLine() + { + var (session, telnet) = Create(World()); + StyledLine? printed = null; + var printedCount = 0; + session.LinePrinted += (_, l) => + { + printed = l; + printedCount++; + }; + StyledLine? changed = null; + session.PromptChanged += (_, p) => changed = p; + await session.ConnectAsync(); + + // Reset past the "*** Connecting..."/"*** Connected." system lines ConnectAsync + // itself prints — those raise LinePrinted too, and would otherwise be mistaken for + // the bug under test. + printed = null; + printedCount = 0; + + // A prompt boundary that carries only an SGR reset and nothing printable — the shape + // AnsiParser.Flush() reports as "nothing buffered" (returns null), which is the case + // WorldSession.OnOutputReceived must not paper over with a blank StyledLine.Empty print. + telnet.EmitPrompt(""); + + await Assert.That(printedCount).IsEqualTo(0); + await Assert.That(printed).IsNull(); + await Assert.That(session.CurrentPrompt).IsNotNull(); + await Assert.That(session.CurrentPrompt!.Text).IsEqualTo(string.Empty); + await Assert.That(session.CurrentPrompt!.IsPrompt).IsTrue(); + await Assert.That(changed).IsNotNull(); + await Assert.That(session.Scrollback.Snapshot().Any(l => l.Text == string.Empty)).IsFalse(); + } +} diff --git a/tests/SharpMUTerm.Core.Tests/Session/WorldSessionTests.cs b/tests/SharpMUTerm.Core.Tests/Session/WorldSessionTests.cs index 709e18c4..b43e04f4 100644 --- a/tests/SharpMUTerm.Core.Tests/Session/WorldSessionTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Session/WorldSessionTests.cs @@ -142,7 +142,7 @@ public async Task Trigger_Spawn_RoutesLineToSpawnEvent() } [Test] - public async Task Prompt_UpdatesCurrentPrompt_WithoutScrollback() + public async Task Prompt_UpdatesCurrentPromptAndLandsInScrollback() { var (session, telnet) = Create(World()); StyledLine? promptEvt = null; @@ -154,7 +154,7 @@ public async Task Prompt_UpdatesCurrentPrompt_WithoutScrollback() await Assert.That(session.CurrentPrompt).IsNotNull(); await Assert.That(session.CurrentPrompt!.Text).IsEqualTo("HP:100 >"); await Assert.That(promptEvt).IsNotNull(); - await Assert.That(session.Scrollback.Snapshot().Any(l => l.Text == "HP:100 >")).IsFalse(); + await Assert.That(session.Scrollback.Snapshot().Any(l => l.Text == "HP:100 >")).IsTrue(); } /// diff --git a/tests/SharpMUTerm.Core.Tests/Text/StyledLineCodecTests.cs b/tests/SharpMUTerm.Core.Tests/Text/StyledLineCodecTests.cs index 41aad2cd..9fa347c0 100644 --- a/tests/SharpMUTerm.Core.Tests/Text/StyledLineCodecTests.cs +++ b/tests/SharpMUTerm.Core.Tests/Text/StyledLineCodecTests.cs @@ -21,6 +21,7 @@ private static async Task AssertRoundTrips(StyledLine line) } await Assert.That(decoded.RuleColor).IsEqualTo(line.RuleColor); + await Assert.That(decoded.IsPrompt).IsEqualTo(line.IsPrompt); } [Test] @@ -37,6 +38,20 @@ public async Task EmptyLineWithRule_KeepsTheRule() await AssertRoundTrips(line); } + [Test] + public async Task PromptFlag_RoundTrips() + { + await AssertRoundTrips(StyledLine.FromText("HP:100>", TextStyle.Default).WithPrompt(true)); + } + + [Test] + public async Task PromptFlag_RoundTripsOnAnEmptyLineWithARule() + { + var line = StyledLine.Empty.WithRule(TerminalColor.FromRgb(1, 2, 3)).WithPrompt(true); + await AssertRoundTrips(line); + await Assert.That(StyledLineCodec.Decode(StyledLineCodec.Encode(line)).IsEmpty).IsTrue(); + } + [Test] public async Task EveryColourKindAndAttributeCombination_RoundTrips() {