diff --git a/CHANGELOG.md b/CHANGELOG.md
index 62b77a9..110228a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -24,6 +24,10 @@ and a release's notes are its section. The procedure is in [`docs/RELEASING.md`]
shows the selected one in full.
- Lua scripts can read and send game data: `gmcp.get('Char.Vitals')`, `msdp.get('HEALTH')` and
`gmcp.send('Char.Skills.Get', { group = 'combat' })`.
+- Worlds & Characters (F5) shows three settings that could only be changed in `config.json`: a world's
+ `content` (`auto`, `ansi`, `mxp` or `pueblo`; `ansi` keeps a world on plain colour even when the server
+ offers MXP), its `emoji` substitution (off, emoticons, shortcodes or both), and a character's
+ `disconnect` commands.
### Changed
@@ -33,6 +37,8 @@ and a release's notes are its section. The procedure is in [`docs/RELEASING.md`]
### Fixed
+- 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
`config.json` when its character's session opens, and the `script` field of a trigger, alias, timer or
macro calls the Lua function it names, with the match and its groups. Until now no script was ever loaded,
diff --git a/src/SharpMUTerm.Core/Configuration/CharacterDefinition.cs b/src/SharpMUTerm.Core/Configuration/CharacterDefinition.cs
index 2aa3334..57f5ce8 100644
--- a/src/SharpMUTerm.Core/Configuration/CharacterDefinition.cs
+++ b/src/SharpMUTerm.Core/Configuration/CharacterDefinition.cs
@@ -106,7 +106,10 @@ public sealed class CharacterDefinition
/// Semicolon-separated commands sent after connecting.
public string? OnConnect { get; set; }
- /// Semicolon-separated commands sent (or run locally) on disconnect.
+ ///
+ /// Semicolon-separated commands sent before the client closes this character's connection itself
+ /// (Disconnect, Reconnect, quitting) — QUIT, typically. A connection the server dropped gets none.
+ ///
public string? OnDisconnect { get; set; }
/// Names of the s that apply to this character.
diff --git a/src/SharpMUTerm.Core/Configuration/WorldDefinition.cs b/src/SharpMUTerm.Core/Configuration/WorldDefinition.cs
index 5f57c76..3c42b1d 100644
--- a/src/SharpMUTerm.Core/Configuration/WorldDefinition.cs
+++ b/src/SharpMUTerm.Core/Configuration/WorldDefinition.cs
@@ -15,7 +15,10 @@ public enum LogFormat
/// How a world's inbound text is interpreted for styling and interaction.
public enum ContentFormat
{
- /// Plain text with ANSI SGR colour (the default).
+ ///
+ /// Plain text with ANSI SGR colour, switched to MXP when the connection negotiates it — the default, and
+ /// so it means "nobody chose" (F5's auto).
+ ///
Ansi,
/// MXP markup (tags, SEND/A links) over ANSI.
@@ -23,6 +26,13 @@ public enum ContentFormat
/// Pueblo HTML-subset markup.
Pueblo,
+
+ ///
+ /// ANSI colour and nothing else, chosen on purpose: a negotiated MXP does not switch the parser (F5's
+ /// ansi). Appended, because the configuration stores these by name and has to
+ /// keep meaning what every saved world already means by it.
+ ///
+ AnsiOnly,
}
/// Emoji/emoticon substitution options.
diff --git a/src/SharpMUTerm.Core/Session/WorldSession.cs b/src/SharpMUTerm.Core/Session/WorldSession.cs
index 802595a..2fe275c 100644
--- a/src/SharpMUTerm.Core/Session/WorldSession.cs
+++ b/src/SharpMUTerm.Core/Session/WorldSession.cs
@@ -26,7 +26,7 @@ public sealed class WorldSession : IAsyncDisposable
private readonly Func _sessionFactory;
private ILineParser _parser;
- private readonly EmojiSubstitutor? _emoji;
+ private EmojiSubstitutor? _emoji;
private ILogSink? _log;
private readonly TextSettings? _text;
private readonly InputSettings? _input;
@@ -82,9 +82,7 @@ public WorldSession(
_input = input;
_charsetOrder = charsetOrder;
_parser = NewParser(world.ContentFormat);
- _emoji = world.Emoji.Enabled
- ? new EmojiSubstitutor(world.Emoji.Emoticons, world.Emoji.Shortcodes)
- : null;
+ _emoji = NewEmoji(world.Emoji);
Scrollback = new ScrollbackBuffer(scrollbackCapacity, CreateSpill(spill ?? new ScrollbackSpillOptions()));
Triggers = new TriggerEngine();
@@ -136,6 +134,9 @@ public void ReloadAutomation(IReadOnlyList triggerSets)
private IScrollbackSpill? CreateSpill(ScrollbackSpillOptions options) =>
options.Enabled ? new FileScrollbackSpill(options, SessionKey) : null;
+ private static EmojiSubstitutor? NewEmoji(EmojiSettings emoji) =>
+ emoji.Enabled ? new EmojiSubstitutor(emoji.Emoticons, emoji.Shortcodes) : null;
+
private static ILineParser CreateParser(ContentFormat format) => format switch
{
ContentFormat.Mxp => new MxpParser(),
@@ -429,6 +430,9 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default)
// negotiated again to take it back off ANSI.
_parser = NewParser(World.ContentFormat);
+ // Re-read with the parser, so an F5 change to the world's emoji switches reaches the next connection.
+ _emoji = NewEmoji(World.Emoji);
+
SetState(ConnectionState.Connecting, null);
PrintSystem($"*** Connecting to {World.Host}:{World.Port}...");
@@ -891,10 +895,44 @@ public async Task DisconnectAsync()
{
if (_telnet is not null)
{
+ await SendLeavingAsync().ConfigureAwait(false);
await _telnet.DisconnectAsync().ConfigureAwait(false);
}
}
+ ///
+ /// Sends the character's commands (QUIT, say)
+ /// before the client closes a connection itself — on Disconnect, Reconnect and quitting the client. A
+ /// connection the server dropped gets none: there is nothing left to send them on.
+ ///
+ /// Bounded by and never throws, because it runs on the way out of the client
+ /// and a hung socket must not hold the exit.
+ ///
+ ///
+ private async Task SendLeavingAsync()
+ {
+ if (!IsConnected || Character is not { } character)
+ {
+ return;
+ }
+
+ using var timeout = new CancellationTokenSource(LeavingTimeout);
+ try
+ {
+ foreach (var command in SplitCommands(character.OnDisconnect))
+ {
+ await SendRawAsync(command, timeout.Token).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex) when (ex is OperationCanceledException or IOException or ObjectDisposedException or InvalidOperationException)
+ {
+ Logger.LogDebug(ex, "{World}: the on-disconnect commands were not all sent", World.Name);
+ }
+ }
+
+ /// How long the on-disconnect commands may take before the connection is closed anyway.
+ internal static readonly TimeSpan LeavingTimeout = TimeSpan.FromSeconds(1);
+
private void SetState(ConnectionState state, Exception? error)
{
State = state;
@@ -956,6 +994,7 @@ public async ValueTask DisposeAsync()
Scrollback.Dispose(); // deletes this session's scrollback spill cache; the log above is kept
if (_telnet is not null)
{
+ await SendLeavingAsync().ConfigureAwait(false);
await _telnet.DisposeAsync().ConfigureAwait(false);
_telnet = null;
}
diff --git a/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs b/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs
index c1f74aa..3d42d6b 100644
--- a/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs
+++ b/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs
@@ -68,6 +68,12 @@ internal static class WorldsScreenRenderer
///
internal const int GmcpModulesField = 5;
+ /// , as .
+ internal const int ContentField = 6;
+
+ /// 's three switches, as one of .
+ internal const int EmojiField = 7;
+
///
/// The character row's field ordinals, in the order ⇥ steps through them — which is the order the
/// CHARACTER form draws them, top to bottom. The name leads, as on every list screen. The password
@@ -83,6 +89,13 @@ internal static class WorldsScreenRenderer
internal const int OnConnectField = 3;
+ ///
+ /// , labelled disconnect as the counterpart of the
+ /// connect line (and because on disconnect is wider than the form's label column). Inserted
+ /// under the on-connect lines, not appended, so ⇥ reads the form top to bottom (the password's rule).
+ ///
+ internal const int OnDisconnectField = 4;
+
///
/// , placed with the connection rows it belongs to:
/// the connect line, the on-connect lines and the login readout all answer "what happens when this
@@ -93,11 +106,11 @@ internal static class WorldsScreenRenderer
/// all. This one has a well, and ⏎ opens it.
///
///
- internal const int StartupField = 4;
+ internal const int StartupField = 5;
- internal const int LogFormatField = 5;
+ internal const int LogFormatField = 6;
- internal const int LogDirectoryField = 6;
+ internal const int LogDirectoryField = 7;
///
/// — whether this character's panes come back holding their
@@ -105,7 +118,7 @@ internal static class WorldsScreenRenderer
/// because it is the third answer to "what does this client write down about me". A field and not a
/// toggle for 's reason.
///
- internal const int RestoreLogField = 7;
+ internal const int RestoreLogField = 8;
///
/// — the colour this character's panes are painted in.
@@ -113,7 +126,7 @@ internal static class WorldsScreenRenderer
/// numbering reason above and because it is the one row on this form that is about
/// appearance: it belongs at the end, where a reader stops looking for behaviour.
///
- internal const int PaneTintField = 8;
+ internal const int PaneTintField = 9;
///
/// The trigger-set row's field ordinals. A set's name leads, as every list row's does — and here it
@@ -413,7 +426,9 @@ internal static ScreenModel Model(
ScreenField.Integer("port", () => w.Port, v => w.Port = v, 1, 65535),
ScreenField.Choice("encoding", () => w.Encoding, v => w.Encoding = v, Encodings),
ScreenField.Integer("keepalive", () => w.KeepaliveSeconds, v => w.KeepaliveSeconds = v, 0, 86400),
- ScreenField.Optional("gmcp modules", () => JoinModules(w), v => SplitModules(w, v))))
+ ScreenField.Optional("gmcp modules", () => JoinModules(w), v => SplitModules(w, v)),
+ ScreenField.Choice("content", () => ContentName(w.ContentFormat), v => w.ContentFormat = ContentOf(v), ContentChoices),
+ ScreenField.Choice("emoji", () => EmojiName(w.Emoji), v => SetEmoji(w.Emoji, v), EmojiChoices)))
.Concat(WorldButtons(worlds, selectedWorld, info))
.ToArray();
@@ -431,6 +446,7 @@ internal static ScreenModel Model(
ScreenField.Defaulted(
"connect", () => c.ConnectString, v => c.ConnectString = v, ConnectStringTemplate.Default),
ScreenField.Optional("on connect", () => c.OnConnect, v => c.OnConnect = v),
+ ScreenField.Optional("disconnect", () => c.OnDisconnect, v => c.OnDisconnect = v),
ScreenField.Choice(
StartupLabel,
() => c.ConnectAtStartup ? StartupOn : StartupOff,
@@ -915,6 +931,20 @@ internal static List DetailColumn(
cursor,
selectedWorld,
GmcpModulesField)),
+ WorldField("content", Field(
+ world.ContentFormat == ContentFormat.Ansi
+ ? $"[{Label}]{ContentName(world.ContentFormat)}[/]"
+ : $"[{Value}]{ContentName(world.ContentFormat)}[/]",
+ cursor,
+ selectedWorld,
+ ContentField)),
+ WorldField("emoji", Field(
+ world.Emoji.Enabled
+ ? $"[{Value}]{EmojiName(world.Emoji)}[/]"
+ : $"[{Label}]{EmojiOff}[/]",
+ cursor,
+ selectedWorld,
+ EmojiField)),
string.Empty,
$"[{accent}]├ CHARACTERS[/] [{Label}]a character is a connection[/]",
});
@@ -1065,6 +1095,12 @@ internal static List FormColumn(
selectedCharacter,
OnConnectField,
pane: CharactersPane)),
+ CharField("disconnect", Field(
+ $"[{Value}]{Escape(character.OnDisconnect ?? "—")}[/]",
+ cursor,
+ selectedCharacter,
+ OnDisconnectField,
+ pane: CharactersPane)),
CharField(StartupLabel, Field(
StartupDetail(character.ConnectAtStartup),
cursor,
@@ -1110,6 +1146,55 @@ internal static List FormColumn(
return ScreenChrome.Choices(form, cursor.Edit, CharDetailColumnWidth);
}
+ ///
+ /// The content field's words. auto is , the stored default,
+ /// which starts on ANSI and follows a negotiated MXP; ansi is the deliberate choice not to.
+ ///
+ internal static readonly IReadOnlyList ContentChoices = ["auto", "ansi", "mxp", "pueblo"];
+
+ internal static string ContentName(ContentFormat format) => format switch
+ {
+ ContentFormat.AnsiOnly => "ansi",
+ ContentFormat.Mxp => "mxp",
+ ContentFormat.Pueblo => "pueblo",
+ _ => "auto",
+ };
+
+ internal static ContentFormat ContentOf(string name) => name.ToLowerInvariant() switch
+ {
+ "ansi" => ContentFormat.AnsiOnly,
+ "mxp" => ContentFormat.Mxp,
+ "pueblo" => ContentFormat.Pueblo,
+ _ => ContentFormat.Ansi,
+ };
+
+ internal const string EmojiOff = "off";
+
+ ///
+ /// The emoji field's words: which of the two substitutions run. Three switches in the
+ /// configuration, one field here, because "on, with neither kind" is not a state anybody wants.
+ ///
+ internal static readonly IReadOnlyList EmojiChoices = [EmojiOff, "emoticons", "shortcodes", "both"];
+
+ internal static string EmojiName(EmojiSettings emoji) => (emoji.Enabled, emoji.Emoticons, emoji.Shortcodes) switch
+ {
+ (false, _, _) or (true, false, false) => EmojiOff,
+ (true, true, false) => "emoticons",
+ (true, false, true) => "shortcodes",
+ _ => "both",
+ };
+
+ internal static void SetEmoji(EmojiSettings emoji, string name)
+ {
+ var choice = name.ToLowerInvariant();
+ emoji.Enabled = choice != EmojiOff;
+ if (emoji.Enabled)
+ {
+ emoji.Emoticons = choice is "emoticons" or "both";
+ emoji.Shortcodes = choice is "shortcodes" or "both";
+ }
+ }
+
///
/// What an unset log directory reads as. The field itself holds null — the logger picks a
/// per-session folder under the config directory — so the row names the state rather than showing
diff --git a/tests/SharpMUTerm.Core.Tests/Session/WorldSessionSettingsTests.cs b/tests/SharpMUTerm.Core.Tests/Session/WorldSessionSettingsTests.cs
new file mode 100644
index 0000000..d7e954c
--- /dev/null
+++ b/tests/SharpMUTerm.Core.Tests/Session/WorldSessionSettingsTests.cs
@@ -0,0 +1,89 @@
+using SharpMUTerm.Core.Configuration;
+using SharpMUTerm.Core.Session;
+
+namespace SharpMUTerm.Core.Tests.Session;
+
+///
+/// The world and character settings F5 gained: the on-disconnect commands, the ansi content choice, and
+/// emoji switches that reach the next connection.
+///
+public class WorldSessionSettingsTests
+{
+ private static WorldDefinition World() => new() { Name = "T", Host = "h", Port = 1 };
+
+ [Test]
+ public async Task DisconnectingSendsTheOnDisconnectCommandsFirst()
+ {
+ var telnet = new FakeTelnetSession();
+ var character = new CharacterDefinition { Name = "Ann", OnDisconnect = "say bye; QUIT" };
+ var session = new WorldSession(World(), character, sessionFactory: _ => telnet);
+ await session.ConnectAsync();
+
+ await session.DisconnectAsync();
+
+ await Assert.That(telnet.SentLines).IsEquivalentTo(new[] { "say bye", "QUIT" });
+ await Assert.That(session.IsConnected).IsFalse();
+ }
+
+ [Test]
+ public async Task QuittingTheClientSendsThemToo()
+ {
+ var telnet = new FakeTelnetSession();
+ var character = new CharacterDefinition { Name = "Ann", OnDisconnect = "QUIT" };
+ var session = new WorldSession(World(), character, sessionFactory: _ => telnet);
+ await session.ConnectAsync();
+
+ await session.DisposeAsync();
+
+ await Assert.That(telnet.SentLines).IsEquivalentTo(new[] { "QUIT" });
+ }
+
+ [Test]
+ public async Task AConnectionTheServerDroppedSendsNothing()
+ {
+ var telnet = new FakeTelnetSession();
+ var character = new CharacterDefinition { Name = "Ann", OnDisconnect = "QUIT" };
+ var session = new WorldSession(World(), character, sessionFactory: _ => telnet);
+ await session.ConnectAsync();
+ await telnet.DisconnectAsync(); // the server's close, as the session sees it
+
+ await session.DisconnectAsync();
+ await session.DisposeAsync();
+
+ await Assert.That(telnet.SentLines).IsEmpty();
+ }
+
+ [Test]
+ public async Task AnAnsiOnlyWorldIsNotSwitchedToMxp()
+ {
+ var world = World();
+ world.ContentFormat = ContentFormat.AnsiOnly;
+ var telnet = new FakeTelnetSession();
+ var session = new WorldSession(world, sessionFactory: _ => telnet);
+ await session.ConnectAsync();
+
+ telnet.RaiseMxpEnabled();
+ telnet.EmitLine("after");
+
+ await Assert.That(session.Scrollback.Snapshot().Any(l => l.Text == "after")).IsTrue();
+ }
+
+ [Test]
+ public async Task EmojiSwitchesChangedBetweenConnectionsApplyToTheNextOne()
+ {
+ var world = World();
+ var telnet = new FakeTelnetSession();
+ var session = new WorldSession(world, sessionFactory: _ => telnet);
+ await session.ConnectAsync();
+ telnet.EmitLine("one :fire:");
+
+ await session.DisconnectAsync();
+ world.Emoji.Enabled = true;
+ await session.ConnectAsync();
+ telnet.EmitLine("two :fire:");
+
+ var lines = session.Scrollback.Snapshot();
+ 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();
+ }
+}
diff --git a/tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs b/tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs
index 80dd00c..4cc3c22 100644
--- a/tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs
+++ b/tests/SharpMUTerm.Tui.Tests/ScreenModelTests.cs
@@ -512,7 +512,7 @@ public async Task Worlds_TheCharacterRowCarriesItsLogFormatFolderAndRestoreSwitc
var model = WorldsScreenRenderer.Model(worlds, Sets(), 0, 0);
var row = model.RowAt(WorldsScreenRenderer.CharactersPane, 0);
- await Assert.That(row.FieldCount).IsEqualTo(9);
+ await Assert.That(row.FieldCount).IsEqualTo(10);
await Assert.That(row.FieldAt(WorldsScreenRenderer.CharacterNameField)!.Value.Get()).IsEqualTo("Kaz");
await Assert.That(row.FieldAt(WorldsScreenRenderer.LogFormatField)!.Value.Get()).IsEqualTo("Html");
await Assert.That(row.FieldAt(WorldsScreenRenderer.LogDirectoryField)!.Value.Get()).IsEqualTo("/logs/kaz");
diff --git a/tests/SharpMUTerm.Tui.Tests/SettingsSessionEditTests.cs b/tests/SharpMUTerm.Tui.Tests/SettingsSessionEditTests.cs
index 8b0139a..d7abe0f 100644
--- a/tests/SharpMUTerm.Tui.Tests/SettingsSessionEditTests.cs
+++ b/tests/SharpMUTerm.Tui.Tests/SettingsSessionEditTests.cs
@@ -441,6 +441,7 @@ public async Task TheCharacterRowStepsFromItsNameThroughToItsLog()
WorldsScreenRenderer.PasswordField,
WorldsScreenRenderer.ConnectStringField,
WorldsScreenRenderer.OnConnectField,
+ WorldsScreenRenderer.OnDisconnectField,
// `at start` sits between the on-connect line and the log values, in the drawn order — the
// walk is asserted in full so a field inserted without being drawn there would fail here.
diff --git a/tests/SharpMUTerm.Tui.Tests/WorldsScreenSettingsTests.cs b/tests/SharpMUTerm.Tui.Tests/WorldsScreenSettingsTests.cs
new file mode 100644
index 0000000..ea37da5
--- /dev/null
+++ b/tests/SharpMUTerm.Tui.Tests/WorldsScreenSettingsTests.cs
@@ -0,0 +1,102 @@
+using SharpMUTerm.Core.Configuration;
+
+namespace SharpMUTerm.Tui.Tests;
+
+///
+/// The settings that only config.json could change until F5 drew them: a world's content format and
+/// emoji switches, and a character's on-disconnect commands. Each is edited through the screen's own field
+/// and read back from the saved form.
+///
+public class WorldsScreenSettingsTests
+{
+ private static List Worlds() =>
+ [
+ new()
+ {
+ Name = "Convergence",
+ Host = "convergence.example",
+ Port = 4201,
+ Characters = { new CharacterDefinition { Name = "Ann" } },
+ },
+ ];
+
+ private static ScreenField WorldField(List worlds, int field) =>
+ WorldsScreenRenderer.Model(worlds, [], 0, 0).FieldAt(WorldsScreenRenderer.WorldsPane, 0, field)!.Value;
+
+ private static WorldDefinition RoundTrip(List worlds)
+ {
+ var config = new AppConfiguration();
+ config.Worlds.AddRange(worlds);
+ return ConfigurationStore.Deserialize(ConfigurationStore.Serialize(config)).Worlds[0];
+ }
+
+ [Test]
+ [Arguments("auto", ContentFormat.Ansi)]
+ [Arguments("ansi", ContentFormat.AnsiOnly)]
+ [Arguments("MXP", ContentFormat.Mxp)]
+ [Arguments("pueblo", ContentFormat.Pueblo)]
+ public async Task ContentIsAChoiceOfFour(string typed, ContentFormat expected)
+ {
+ var worlds = Worlds();
+ var field = WorldField(worlds, WorldsScreenRenderer.ContentField);
+
+ await Assert.That(field.Get()).IsEqualTo("auto");
+ await Assert.That(new ScreenEdits().Apply(field, typed)).IsNull();
+
+ await Assert.That(worlds[0].ContentFormat).IsEqualTo(expected);
+ await Assert.That(RoundTrip(worlds).ContentFormat).IsEqualTo(expected);
+ await Assert.That(new ScreenEdits().Apply(field, "html")).IsNotNull();
+ }
+
+ [Test]
+ [Arguments("off", false, true, true)]
+ [Arguments("emoticons", true, true, false)]
+ [Arguments("shortcodes", true, false, true)]
+ [Arguments("both", true, true, true)]
+ public async Task EmojiIsOneFieldOverThreeSwitches(string typed, bool enabled, bool emoticons, bool shortcodes)
+ {
+ var worlds = Worlds();
+ var field = WorldField(worlds, WorldsScreenRenderer.EmojiField);
+
+ await Assert.That(field.Get()).IsEqualTo("off");
+ await Assert.That(new ScreenEdits().Apply(field, typed)).IsNull();
+
+ var saved = RoundTrip(worlds).Emoji;
+ await Assert.That((saved.Enabled, saved.Emoticons, saved.Shortcodes)).IsEqualTo((enabled, emoticons, shortcodes));
+ await Assert.That(field.Get()).IsEqualTo(typed);
+ }
+
+ [Test]
+ public async Task OnDisconnectSitsUnderOnConnect()
+ {
+ var worlds = Worlds();
+ var model = WorldsScreenRenderer.Model(worlds, [], 0, 0);
+ var field = model.FieldAt(WorldsScreenRenderer.CharactersPane, 0, WorldsScreenRenderer.OnDisconnectField)!.Value;
+
+ await Assert.That(field.Label).IsEqualTo("disconnect");
+ await Assert.That(model.FieldAt(WorldsScreenRenderer.CharactersPane, 0, WorldsScreenRenderer.OnConnectField)!.Value.Label)
+ .IsEqualTo("on connect");
+ await Assert.That(new ScreenEdits().Apply(field, "QUIT")).IsNull();
+
+ await Assert.That(RoundTrip(worlds).Characters[0].OnDisconnect).IsEqualTo("QUIT");
+ }
+
+ [Test]
+ public async Task TheDetailColumnShowsAllThree()
+ {
+ var worlds = Worlds();
+ worlds[0].ContentFormat = ContentFormat.Mxp;
+ worlds[0].Emoji.Enabled = true;
+ worlds[0].Characters[0].OnDisconnect = "QUIT";
+
+ var text = string.Join("\n", WorldsScreenRenderer.DetailColumn(worlds, [], 0, 0, "#ffffff").Select(MarkupText.Plain));
+
+ await Assert.That(text).Contains("content");
+ await Assert.That(text).Contains("mxp");
+ await Assert.That(text).Contains("both");
+
+ var form = string.Join("\n", WorldsScreenRenderer.FormColumn(worlds[0].Characters[0], "#ffffff").Select(MarkupText.Plain));
+ await Assert.That(form).Contains("disconnect QUIT");
+ await Assert.That(form).Contains("QUIT");
+ }
+}