From 85de28d3cd7a89dd1951ae755b9046e7796ed1de Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 05:06:26 +0000 Subject: [PATCH] Put content format, emoji and on-disconnect on F5 Three settings existed only in config.json. F5's world row gains content (auto/ansi/mxp/pueblo) and emoji (off/emoticons/shortcodes/ both); the character form gains disconnect under on connect. ContentFormat gains AnsiOnly, appended so a stored "Ansi" keeps meaning the automatic default. OnDisconnect was saved and never sent; it now goes out before the client closes a connection itself, bounded by a one-second timeout. Emoji switches are re-read on each connect. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01WXyHEoxwX8GurTHxwPinQY --- CHANGELOG.md | 6 ++ .../Configuration/CharacterDefinition.cs | 5 +- .../Configuration/WorldDefinition.cs | 12 ++- src/SharpMUTerm.Core/Session/WorldSession.cs | 47 +++++++- src/SharpMUTerm.Tui/WorldsScreenRenderer.cs | 97 +++++++++++++++-- .../Session/WorldSessionSettingsTests.cs | 89 +++++++++++++++ .../SharpMUTerm.Tui.Tests/ScreenModelTests.cs | 2 +- .../SettingsSessionEditTests.cs | 1 + .../WorldsScreenSettingsTests.cs | 102 ++++++++++++++++++ 9 files changed, 348 insertions(+), 13 deletions(-) create mode 100644 tests/SharpMUTerm.Core.Tests/Session/WorldSessionSettingsTests.cs create mode 100644 tests/SharpMUTerm.Tui.Tests/WorldsScreenSettingsTests.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 62b77a9d..110228ac 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 2aa33342..57f5ce8f 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 5f57c761..3c42b1d9 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 802595a7..2fe275c2 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 c1f74aac..3d42d6b6 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 00000000..d7e954c2 --- /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 80dd00c3..4cc3c22e 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 8b0139aa..d7abe0f7 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 00000000..ea37da59 --- /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"); + } +}