diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index acd9795..4fe101a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -37,7 +37,7 @@ jobs:
# (the classic `dotnet test`/VSTest path is not used by MTP on .NET 10).
#
# Every suite carries `if: ${{ !cancelled() }}` so one failure does not hide the rest. Without it
- # the job stops at the first red step, and a Core failure means Graphics through Crawler simply do
+ # the job stops at the first red step, and a Core failure means Graphics through Tui simply do
# not run — which is how a Windows file-sharing defect in Core kept two Tui failures invisible for
# days: nobody knew they were there until Core went green. A cancelled run still stops.
- name: Test — Core
@@ -64,14 +64,6 @@ jobs:
shell: bash
run: dotnet run -c Release --no-build --project tests/SharpMUTerm.Tui.Tests/SharpMUTerm.Tui.Tests.csproj
- # The Crawler suite was not listed here at all — on either platform, not merely on Windows. Its 102
- # tests have never run in CI, which is why a FileShare omission in ObservationLog survived the
- # sweep that fixed the same pattern in the spill, the restore log and both transcript sinks.
- - name: Test — Crawler
- if: ${{ !cancelled() }}
- shell: bash
- run: dotnet run -c Release --no-build --project tests/SharpMUTerm.Crawler.Tests/SharpMUTerm.Crawler.Tests.csproj
-
# End-to-end guard: the headless snapshot must render the workspace (rail, worlds, command
# surface) and exit — catches both UI-render regressions and any return of the stdin-block hang.
- name: Smoke — headless snapshot
diff --git a/CLAUDE.md b/CLAUDE.md
index a0cb78b..62fc099 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -49,7 +49,7 @@ fallbacks) for inline images/maps.
paged off an ephemeral per-session cache under `$XDG_CACHE_HOME`; absolute line indices, ranged
reads capped at `MaxRangeLines`, and any disk failure degrades to memory-only. Emphatically **not**
the session log — that stays `PlainTextLogSink`/`HtmlLogSink`, opt-in and kept),
- `TcpTransport` (TLS + IPv6), `TelnetSession` (wraps TelnetNegotiationCore **2.6.5**),
+ `TcpTransport` (TLS + IPv6), `TelnetSession` (wraps TelnetNegotiationCore **2.7.0**),
trigger/alias/macro engines + `IntervalScheduler`, plain-text + HTML logging, versioned JSON
config (worlds → characters + shared trigger sets, with migration),
`Theme`/`ThemeLibrary`, and `WorldSession`/`SessionManager` orchestration.
@@ -103,6 +103,26 @@ fallbacks) for inline images/maps.
Restored content is closed off by one `RestoreBarRenderer` row and the lines themselves are left
alone. Restoring 3,000 lines costs ~18 ms before the first frame. `restore:` is the third member of
the `save:`/`logRoot:` family — **null by default, so no test and no snapshot owns one**.
+- **Every server's MSSP report is kept, and the INFO screen reads it** (`MsspCache`, Core; `mssp.json`
+ beside `config.json`, keyed by `host:port`; F5 ▸ `i`). Fourth of the `save:`/`logRoot:`/`restore:`
+ family with **one deliberate difference**: the constructor parameter is null by default like the
+ others, but the *field* never is — a `MsspCache` with no path is memory-only **by construction**, so
+ the "a snapshot writes nothing" guarantee is a property of the object rather than a null check at
+ each use site, and the screen needs no "is there a cache" branch. Three decisions worth not
+ relitigating. **Keyed by endpoint, not world**: MSSP describes a *server*, a world name is a
+ user-editable label two entries may share, and a rename must not lose a report. **A second report
+ replaces the first**: MSSP is not a delta protocol — a server sends its whole table once per
+ connection — so a merge would keep variables it has stopped publishing and would leave a report that
+ is a snapshot of no moment that existed. **Two timestamps**, because there are *three* states and two
+ would only separate two: `ConnectedAt` is written on the `Connected` transition and `ObservedAt` only
+ when a report arrives, so "never dialled", "dialled and publishes nothing" and "here is the report,
+ as of…" are three different screens. Report capture is bounded at the door
+ (`MaxVariables`/`MaxValuesPerVariable`/`MaxValueLength`), not only at the renderer — a value only the
+ screen trimmed would still be full size on disk and in memory on every later launch.
+- **`IAC DO MSSP` is sent on connect** (`TelnetSessionOptions.RequestOptions`, set by `WorldSession`'s
+ session factory). The library opens with `IAC WILL NAWS` and nothing else, so a server that supports
+ MSSP but waits to be asked is never asked — and the INFO screen would then report it as publishing
+ none, which is a claim about the server made out of our own silence.
- **A launch connects nothing unless it is told to** (`StartupConnections.Resolve`, Core). A host on the
command line wins outright; otherwise it is every character with `ConnectAtStartup` (F5's `at start`),
in configuration order; otherwise none, and the client says which of the two empty states it is in.
@@ -131,8 +151,8 @@ fallbacks) for inline images/maps.
```bash
dotnet run -c Release --project tests/SharpMUTerm.Core.Tests
-
-
-
-
-
-
-
diff --git a/src/SharpMUTerm.Crawler/Storage/CrawlStore.cs b/src/SharpMUTerm.Crawler/Storage/CrawlStore.cs
deleted file mode 100644
index 781dd46..0000000
--- a/src/SharpMUTerm.Crawler/Storage/CrawlStore.cs
+++ /dev/null
@@ -1,197 +0,0 @@
-using System.Text.Json;
-using System.Text.Json.Serialization;
-using SharpMUTerm.Core.Telnet.Mssp;
-using SharpMUTerm.Crawler.Model;
-
-namespace SharpMUTerm.Crawler.Storage;
-
-///
-/// The crawl's memory between runs: every host it has ever heard of, how each attempt went, and when
-/// each may next be attempted.
-///
-/// Without this a second run is a first run: it would re-dial every host it had just visited, the
-/// revisit interval would describe nothing, and a host that has refused a hundred times would be
-/// refused a hundred and one. The whole of the politeness story depends on the crawler remembering,
-/// so the store is written after every observation rather than at the end of a run — a crawl killed
-/// half way through must not forget the half it did.
-///
-///
-/// It is a plain JSON file under the run's own output directory. Never the user's configuration
-/// directory: this tool reads no configuration of the client's and writes none.
-///
-///
-public sealed class CrawlStore(string path)
-{
- /// Bumped when the on-disk shape changes in a way an older file cannot be read as.
- public const int CurrentVersion = 1;
-
- private static readonly JsonSerializerOptions Json = new()
- {
- WriteIndented = true,
- // camelCase, matched case-insensitively on the way in, so a file written by an older build or
- // edited by hand still loads.
- PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
- PropertyNameCaseInsensitive = true,
- DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
- Converters = { new JsonStringEnumConverter() },
- };
-
- public string Path { get; } = path;
-
- ///
- /// Reads the previous run's state, or returns nothing when there is none.
- ///
- /// A file that cannot be parsed — a truncated write, a version from the future — is reported and
- /// treated as absent rather than throwing. Losing the memory of a crawl costs one round of extra
- /// politeness; refusing to start because of it costs the run.
- ///
- ///
- public IReadOnlyList Load(out string? problem)
- {
- problem = null;
- if (!File.Exists(Path))
- {
- return [];
- }
-
- try
- {
- var document = JsonSerializer.Deserialize(File.ReadAllText(Path), Json);
- if (document is null)
- {
- problem = "the state file was empty";
- return [];
- }
-
- if (document.Version > CurrentVersion)
- {
- problem = $"the state file is version {document.Version}, newer than this build understands";
- return [];
- }
-
- return document.Hosts.Select(FromEntry).OfType().ToList();
- }
- catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException)
- {
- problem = $"the state file could not be read ({ex.GetType().Name})";
- return [];
- }
- }
-
- ///
- /// Writes the state, atomically: to a temporary file beside the real one, then replaced over it.
- /// A crawl that is killed mid-write must not leave a state file that cannot be read, because the
- /// consequence of losing it is re-visiting everyone.
- ///
- public void Save(IEnumerable records)
- {
- var document = new StateDocument
- {
- Version = CurrentVersion,
- SavedAt = DateTimeOffset.UtcNow,
- Hosts = records.Select(ToEntry).OrderBy(entry => entry.Host, StringComparer.Ordinal).ToList(),
- };
-
- var directory = System.IO.Path.GetDirectoryName(System.IO.Path.GetFullPath(Path));
- if (!string.IsNullOrEmpty(directory))
- {
- Directory.CreateDirectory(directory);
- }
-
- var temporary = Path + ".tmp";
- File.WriteAllText(temporary, JsonSerializer.Serialize(document, Json));
- File.Move(temporary, Path, overwrite: true);
- }
-
- private static HostEntry ToEntry(HostRecord record) => new()
- {
- Host = record.Host.Host,
- Port = record.Host.Port,
- Depth = record.Depth,
- DiscoveredFrom = record.DiscoveredFrom?.ToReferralString(),
- FirstSeen = record.FirstSeen,
- LastAttempt = record.LastAttempt,
- LastSuccess = record.LastSuccess,
- LastOutcome = record.LastOutcome,
- LastError = record.LastError,
- Name = record.Name,
- CrawlDelayHours = record.CrawlDelayHours,
- ConsecutiveFailures = record.ConsecutiveFailures,
- Attempts = record.Attempts,
- NotBefore = record.NotBefore,
- Retired = record.Retired,
- };
-
- private static HostRecord? FromEntry(HostEntry entry)
- {
- // Re-normalising through MsspHost.Create rather than trusting the file: a state file written by
- // an older build (or edited by hand) may hold a spelling that is no longer canonical, and two
- // spellings of one host in the frontier is exactly the bug the normalisation exists to prevent.
- if (MsspHost.Create(entry.Host, entry.Port) is not { } host)
- {
- return null;
- }
-
- MsspHost.TryParse(entry.DiscoveredFrom, out var from);
-
- return new HostRecord
- {
- Host = host,
- Depth = entry.Depth,
- DiscoveredFrom = from,
- FirstSeen = entry.FirstSeen,
- LastAttempt = entry.LastAttempt,
- LastSuccess = entry.LastSuccess,
- LastOutcome = entry.LastOutcome,
- LastError = entry.LastError,
- Name = entry.Name,
- CrawlDelayHours = entry.CrawlDelayHours,
- ConsecutiveFailures = entry.ConsecutiveFailures,
- Attempts = entry.Attempts,
- NotBefore = entry.NotBefore,
- Retired = entry.Retired,
- };
- }
-
- private sealed class StateDocument
- {
- public int Version { get; set; }
-
- public DateTimeOffset SavedAt { get; set; }
-
- public List Hosts { get; set; } = [];
- }
-
- private sealed class HostEntry
- {
- public string Host { get; set; } = string.Empty;
-
- public int Port { get; set; }
-
- public int Depth { get; set; }
-
- public string? DiscoveredFrom { get; set; }
-
- public DateTimeOffset FirstSeen { get; set; }
-
- public DateTimeOffset? LastAttempt { get; set; }
-
- public DateTimeOffset? LastSuccess { get; set; }
-
- public CrawlOutcome LastOutcome { get; set; }
-
- public string? LastError { get; set; }
-
- public string? Name { get; set; }
-
- public double? CrawlDelayHours { get; set; }
-
- public int ConsecutiveFailures { get; set; }
-
- public int Attempts { get; set; }
-
- public DateTimeOffset? NotBefore { get; set; }
-
- public bool Retired { get; set; }
- }
-}
diff --git a/src/SharpMUTerm.Tui/DemoScene.cs b/src/SharpMUTerm.Tui/DemoScene.cs
index ca1dcca..5781ba3 100644
--- a/src/SharpMUTerm.Tui/DemoScene.cs
+++ b/src/SharpMUTerm.Tui/DemoScene.cs
@@ -1,6 +1,7 @@
using SharpMUTerm.Core.Automation;
using SharpMUTerm.Core.Configuration;
using SharpMUTerm.Core.Telnet;
+using SharpMUTerm.Core.Telnet.Mssp;
using SharpMUTerm.Core.Text;
using SharpMUTerm.Core.Workspaces;
@@ -80,6 +81,53 @@ private static void AddWorlds(AppConfiguration config)
});
}
+ ///
+ /// The MSSP report the demo's Aetherfall is pretended to have published, for the mssp
+ /// snapshot view.
+ ///
+ /// It is written through SharpMUTermApp.CaptureMssp — the same method the live
+ /// subscription calls — and not poked into a cache here. The demo has no session, so anything a
+ /// session writes has to be written by hand; three separate bugs have hidden in exactly that gap,
+ /// and the rule this file already carries for the main window's title is the rule here: pin the
+ /// faked state against the live writer rather than against a second copy of what it does.
+ ///
+ ///
+ /// The contents are chosen to make the screen's own hard cases visible in one frame: a multi-valued
+ /// PORT and CODEBASE (drawn as the lists they are, not as their first value), a
+ /// -1 world count (drawn as unknown), an UPTIME that is a Unix timestamp
+ /// (drawn as a duration), an official variable with no strongly typed reading (CHARSET), an
+ /// unofficial one that looks standard (PUEBLO), and one nothing anywhere has heard of.
+ ///
+ ///
+ public static MsspData MsspReport() => MsspData.From(
+ [
+ Variable("NAME", "Aetherfall"),
+ Variable("PLAYERS", "37"),
+ Variable("UPTIME", "1735689600"),
+ Variable("CODEBASE", "PennMUSH 1.8.8", "SharpMUSH 1.0"),
+ Variable("FAMILY", "TinyMUD"),
+ Variable("HOSTNAME", "aetherfall.mux"),
+ Variable("PORT", "4200", "4201"),
+ Variable("SSL", "4201"),
+ Variable("CHARSET", "UTF-8"),
+ Variable("CONTACT", "wizards@aetherfall.mux"),
+ Variable("WEBSITE", "https://aetherfall.mux/"),
+ Variable("GENRE", "Fantasy"),
+ Variable("STATUS", "Live"),
+ Variable("MINIMUM AGE", "16"),
+ Variable("LANGUAGE", "English"),
+ Variable("LOCATION", "United Kingdom"),
+ Variable("ROOMS", "-1"),
+ Variable("ANSI", "1"),
+ Variable("UTF-8", "1"),
+ Variable("PUEBLO", "1"),
+ Variable("DISCORD", "https://discord.gg/aetherfall"),
+ Variable("CORVID SPECIFIC", "nevermore"),
+ ]);
+
+ private static KeyValuePair> Variable(string name, params string[] values) =>
+ new(name, values);
+
private static void AddTriggerSets(AppConfiguration config)
{
var teal = TerminalColor.FromRgb(0x00, 0xf5, 0xb7);
diff --git a/src/SharpMUTerm.Tui/MsspScreenRenderer.cs b/src/SharpMUTerm.Tui/MsspScreenRenderer.cs
new file mode 100644
index 0000000..21be61b
--- /dev/null
+++ b/src/SharpMUTerm.Tui/MsspScreenRenderer.cs
@@ -0,0 +1,449 @@
+using System.Globalization;
+using SharpMUTerm.Core.Configuration;
+using SharpMUTerm.Core.Telnet.Mssp;
+using static SharpMUTerm.Tui.MarkupText;
+using static SharpMUTerm.Tui.ScreenPalette;
+
+namespace SharpMUTerm.Tui;
+
+///
+/// The read-only MSSP report for one world — what the server said about itself, and when it said it.
+/// Reached from F5 with i on the selected world.
+///
+/// Read-only is the shape, not an omission. Every other settings screen exists to change
+/// something, and this project's affordance rule is that a field well means "the keyboard can change
+/// this here" and its absence means it cannot (). A whole screen of
+/// well-less rows is therefore exactly right, and it has to be deliberately well-less so it does
+/// not read as a form whose wells failed to render. Nothing here calls .
+///
+///
+/// Three states, and the two empty ones must not look the same. A world we have never connected
+/// to, a server that answered and publishes no MSSP, and a report. Conflating the last two is the easy
+/// mistake and it is the one that makes a client look broken: on a MUSH, "this server publishes no MSSP"
+/// is the ordinary answer, and saying so — with what we do know from the world's own configuration
+/// beside it — is the same information as "no data" and the opposite impression.
+///
+///
+/// Every value here came off the wire from a stranger. Three things follow, and all three
+/// happen in Row in this order: control characters are replaced (a raw newline in a value would
+/// otherwise end the row and shift everything below it), the raw text is truncated before
+/// escaping (truncating escaped markup can split a [[ pair into an unbalanced tag), and only
+/// then is it escaped. Column widths are functions of the terminal and never of the data,
+/// because this repository has twice paid for chrome whose width was a function of something arriving
+/// from the wire — RailRenderer.UnsentFieldWidth, and the status row's scrollback distance that
+/// wrapped every pane's height at 99 → 100.
+///
+///
+internal static class MsspScreenRenderer
+{
+ /// The screen's title, and what the ⌃P entry and the snapshot view are named after.
+ internal const string Title = "Server information";
+
+ /// The snapshot view name (--view mssp).
+ internal const string View = "mssp";
+
+ ///
+ /// How wide the variable-name column runs. Official MSSP names top out at XTERM 256 COLORS
+ /// (17) and HIRING BUILDERS (15); an invented one can be any length at all, so the column is
+ /// a constant and a longer name is elided into it rather than being allowed to push the values
+ /// right. See the type summary for why that is not fussiness.
+ ///
+ internal const int NameWidth = 20;
+
+ ///
+ /// The most of one value that is ever drawn. A server may legitimately send a long WEBSITE or
+ /// a wordy DESCRIPTION; it may also send half a megabyte.
+ /// already bounds what is stored — this bounds what one row spends, which is the layout half of the
+ /// same rule.
+ ///
+ internal const int ValueWidth = 58;
+
+ /// The fewest cells a value is still worth drawing in, on a terminal too narrow for more.
+ private const int MinValueWidth = 16;
+
+ /// What a row spends before the value: the mark column, the name column, and their gaps.
+ private const int RowChrome = 1 + 1 + 1 + NameWidth + 2;
+
+ ///
+ /// How much of a value this frame can afford — where there is room for it,
+ /// and what is left of the terminal where there is not.
+ ///
+ /// A function of the terminal, never of the data. That is the whole discipline: this
+ /// repository has twice shipped chrome whose width was a function of something arriving from the
+ /// wire, and both times the symptom was a row growing past its box and taking a row off everything
+ /// below it. A value at the full 58 cells needs 83, so at 80 columns the constant alone would have
+ /// overrun by three — visible in a rendered frame and in nothing else.
+ ///
+ ///
+ private static int ValueCells(int width) =>
+ width <= 0 ? ValueWidth : Math.Clamp(width - RowChrome - 1, MinValueWidth, ValueWidth);
+
+ ///
+ /// How many values of one variable are listed before the rest are summarised. Multi-valued variables
+ /// are real and are the reason the model is a name → list map at all (PORT,
+ /// REFERRAL, CODEBASE), so they are drawn as the list they are — one value per row,
+ /// the name printed once — rather than as their first or last value. This caps the rows one variable
+ /// can spend.
+ ///
+ internal const int MaxValueRows = 8;
+
+ /// What a row shows where the server said nothing at all.
+ internal const string Unreported = "—";
+
+ /// What a numeric world variable of -1 reads as: the specification's "not available".
+ internal const string Unavailable = "unknown";
+
+ /// The words the never-connected state is put in.
+ internal const string NeverConnected = "No server information yet — connect once and this fills in.";
+
+ ///
+ /// The words the connected-and-silent state is put in. It says the absence is normal because it is:
+ /// MSSP is optional, most MUSHes do not implement it, and a client that reported the ordinary case
+ /// as a failure would be teaching people to distrust the screen.
+ ///
+ internal const string NoMssp = "This server does not publish MSSP. It is optional, and most MUSHes do not.";
+
+ ///
+ /// The first-class rows, in the order somebody browsing a world list wants them. Everything not on
+ /// this list is still shown — under — because a protocol whose entire
+ /// purpose is servers describing themselves is a protocol whose unofficial half is where the
+ /// interesting things are.
+ ///
+ private static readonly (string Label, string Variable)[] Headline =
+ [
+ ("name", MsspVariables.Name),
+ ("players", MsspVariables.Players),
+ ("uptime", MsspVariables.Uptime),
+ ("codebase", MsspVariables.Codebase),
+ ("family", MsspVariables.Family),
+ ("hostname", MsspVariables.Hostname),
+ ("port", MsspVariables.Port),
+ ("ssl", MsspVariables.Ssl),
+ ("charset", MsspVariables.Charset),
+ ("contact", MsspVariables.Contact),
+ ("website", MsspVariables.Website),
+ ("genre", MsspVariables.Genre),
+ ("status", MsspVariables.Status),
+ ("minimum age", MsspVariables.MinimumAge),
+ ];
+
+ /// The heading over the variables that did not earn a headline row.
+ internal const string EverythingElse = "EVERYTHING ELSE";
+
+ /// The heading over the headline rows.
+ internal const string SummaryHeading = "SERVER";
+
+ ///
+ /// How an unofficial variable is marked. Official and unofficial must both be visible and must be
+ /// told apart: a name the specification defines is a claim a crawler and a client read the same way,
+ /// and a name somebody's codebase invented is not — and the reader cannot tell which is which from
+ /// the name alone (DISCORD is official as of 2.7.0; PUEBLO, which looks every bit as
+ /// standard, is not).
+ ///
+ internal const string UnofficialMark = "·";
+
+ /// The legend that says what means, so the mark is readable.
+ internal const string UnofficialLegend = "not in the MSSP specification";
+
+ ///
+ /// The screen's navigable shape: one stop per drawn body row, so ↑↓ scroll a report longer than the
+ /// screen through .
+ ///
+ /// There is nothing to edit, nothing to toggle and nothing to remove, and the shape says so: the
+ /// header offers no ⏎ edit, no Space toggle and no Del remove, because every one
+ /// of those hints is derived from this model rather than written by the screen.
+ ///
+ ///
+ internal static ScreenModel Model(
+ WorldDefinition? world, MsspObservation? observation, DateTimeOffset now, int width = 0) =>
+ new(ScreenModel.Stops(Body(world, observation, now, width).Count));
+
+ ///
+ /// The screen's header band: its title, and the one key that leaves it. It does not go through
+ /// because that composes a settings screen's contract —
+ /// F5/Esc close — and this screen's Esc does something else: it goes back to the
+ /// screen that opened it, with its selection intact. A header offering "close" would name the right
+ /// key for the wrong outcome.
+ ///
+ internal static string HeaderLine(int width)
+ {
+ var hints = $"[{Label}]↑↓ scroll · [/][{Accent}]Esc[/][{Label}] back [/]";
+ return SpreadLR($" [bold {Value}] {Escape(Title)}[/]", hints, width);
+ }
+
+ /// The screen's action bar: where the cursor is in the report, and the key that leaves.
+ internal static string FooterLine(
+ WorldDefinition? world, MsspObservation? observation, ScreenFocus? focus, int width, DateTimeOffset now)
+ {
+ var rows = Body(world, observation, now, width).Count;
+ var context = ScreenChrome.Context(
+ world is null ? null : Escape(world.Name),
+ observation is null ? null : Escape(observation.Endpoint),
+ focus is { } cursor && cursor.Index >= 0 && rows > 0
+ ? ScreenChrome.Position("row", cursor.Index, rows)
+ : null);
+
+ return SpreadLR(
+ " " + context,
+ $"[{Label}] [[Esc]] Back [/]",
+ width);
+ }
+
+ ///
+ /// The whole report as markup rows, with the cursor band on the focused one and the block windowed
+ /// to . is the same rows without either, which is what
+ /// counts — so the number of cursor stops and the number of drawn rows are one
+ /// number by construction rather than by two functions agreeing.
+ ///
+ internal static List Render(
+ WorldDefinition? world,
+ MsspObservation? observation,
+ DateTimeOffset now,
+ ScreenFocus? focus = null,
+ int height = 0,
+ int width = 0)
+ {
+ var cursor = focus ?? ScreenFocus.None;
+ var body = Body(world, observation, now, width);
+ for (var i = 0; i < body.Count; i++)
+ {
+ // The bar spans the terminal, not the columns: this screen is one pane filling the window,
+ // so a bar that stopped where the value column stops would read as a second, invisible
+ // column edge. On F5 the same call pads to the pane's width, which is the same rule.
+ body[i] = ScreenChrome.Cursor(
+ body[i], cursor.IsOn(0, i), width > 0 ? width : RowChrome + ValueWidth);
+ }
+
+ return ScreenChrome.Window(body, height);
+ }
+
+ ///
+ /// The report's rows, unfocused and unwindowed. Deterministic in its arguments, because
+ /// counts these rows and draws them: two functions that
+ /// disagreed about how many there are would give the screen a cursor stop nobody ever drew, which is
+ /// the failure exists one level up to stop.
+ ///
+ internal static List Body(
+ WorldDefinition? world, MsspObservation? observation, DateTimeOffset now, int width = 0)
+ {
+ var cells = ValueCells(width);
+ var rows = new List();
+ AddConfigured(rows, world, cells);
+
+ if (observation is null)
+ {
+ rows.Add(string.Empty);
+ rows.Add($" [{Muted}]{Escape(NeverConnected)}[/]");
+ return rows;
+ }
+
+ if (observation is { Report: null } or { ObservedAt: null })
+ {
+ rows.Add(string.Empty);
+ rows.Add($" [{Muted}]{Escape(NoMssp)}[/]");
+ rows.Add(string.Empty);
+ rows.Add(Row("last seen", Since(observation.ConnectedAt, now), cells));
+ return rows;
+ }
+
+ var report = observation.Report!;
+
+ // Built once. MsspData.UnofficialNames allocates and filters on every read, and the mark column
+ // asks per row — a hundred-variable report would have walked it a hundred times.
+ var unofficial = new HashSet(report.UnofficialNames, StringComparer.Ordinal);
+ rows.Add(string.Empty);
+ rows.Add(Row("captured", Since(observation.ObservedAt!.Value, now), cells));
+ rows.Add(string.Empty);
+ rows.Add($" [{Label}]{SummaryHeading}[/]");
+
+ var drawn = new HashSet(StringComparer.Ordinal);
+ foreach (var (label, variable) in Headline)
+ {
+ drawn.Add(variable);
+ AddVariable(rows, label, report[variable], variable, report, unofficial, now, cells);
+ }
+
+ var remaining = report.Keys.Where(name => !drawn.Contains(name)).ToList();
+ rows.Add(string.Empty);
+ rows.Add($" [{Label}]{EverythingElse}[/]");
+ if (remaining.Count == 0)
+ {
+ rows.Add($" [{Muted}]nothing else was sent[/]");
+ return rows;
+ }
+
+ foreach (var name in remaining)
+ {
+ AddVariable(rows, name, report[name], name, report, unofficial, now, cells);
+ }
+
+ rows.Add(string.Empty);
+ rows.Add($" [{Muted}]{UnofficialMark} {Escape(UnofficialLegend)}[/]");
+ return rows;
+ }
+
+ ///
+ /// What the client knows without asking anybody: the world's own name, host, port and whether the
+ /// connection is encrypted. It heads every state, including both empty ones — which is the point.
+ /// A screen that had nothing at all to show for a world it has never reached would be a screen you
+ /// stop opening; one that shows the configuration it does hold answers half the question.
+ ///
+ private static void AddConfigured(List rows, WorldDefinition? world, int cells)
+ {
+ if (world is null)
+ {
+ rows.Add($" [{Muted}]no world selected[/]");
+ return;
+ }
+
+ rows.Add(Row("world", world.Name, cells));
+ rows.Add(Row(
+ "address",
+ $"{world.Host}:{world.Port.ToString(CultureInfo.InvariantCulture)}",
+ cells));
+ rows.Add(Row("transport", world.UseTls ? "TLS" : "plain", cells));
+ }
+
+ ///
+ /// One variable, as one row per value. The name is printed on the first row only, so a three-port
+ /// server reads as one variable with three values rather than as three variables — and the values
+ /// are drawn least-to-most-relevant, in wire order, which is the order the specification gives them
+ /// meaning in ("the last reported value should be used as the default value").
+ ///
+ private static void AddVariable(
+ List rows,
+ string label,
+ IReadOnlyList values,
+ string variable,
+ MsspData report,
+ IReadOnlySet unofficial,
+ DateTimeOffset now,
+ int cells)
+ {
+ var mark = unofficial.Contains(variable) ? UnofficialMark : " ";
+
+ if (values.Count == 0)
+ {
+ // Two different absences, and the row says which. A variable the server sent with no value
+ // is a fact about the server; one it never mentioned is a fact about the report.
+ rows.Add(Row(label, report.ContainsKey(variable) ? Unavailable : Unreported, cells, mark));
+ return;
+ }
+
+ var shown = Math.Min(values.Count, MaxValueRows);
+ for (var i = 0; i < shown; i++)
+ {
+ rows.Add(Row(
+ i == 0 ? label : string.Empty,
+ Reading(variable, values[i], now),
+ cells,
+ i == 0 ? mark : " "));
+ }
+
+ if (values.Count > shown)
+ {
+ rows.Add(Row(
+ string.Empty,
+ $"… {(values.Count - shown).ToString(CultureInfo.InvariantCulture)} more",
+ cells));
+ }
+ }
+
+ ///
+ /// How one value reads. Almost all of them read as themselves; the two exceptions are the ones where
+ /// the raw string is actively misleading — UPTIME is a Unix timestamp, which is a number
+ /// nobody can read as a duration, and -1 is the specification's marker for "this server
+ /// cannot count that" rather than a count of minus one.
+ ///
+ private static string Reading(string variable, string value, DateTimeOffset now)
+ {
+ if (string.Equals(variable, MsspVariables.Uptime, StringComparison.Ordinal)
+ && long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var unix)
+ && unix > 0)
+ {
+ var booted = DateTimeOffset.FromUnixTimeSeconds(unix);
+ return $"{Duration(now - booted)} (since {booted.UtcDateTime:yyyy-MM-dd HH:mm} UTC)";
+ }
+
+ return string.Equals(value, "-1", StringComparison.Ordinal) ? Unavailable : value;
+ }
+
+ ///
+ /// A label value row. Both halves are sanitised, truncated and escaped, and the whole thing
+ /// carries a one-cell mark column — reserved, blank when the variable is official, so a report of
+ /// unofficial variables and one of official variables put their values in the same column.
+ ///
+ /// The label is padded before it is escaped, and it is a label off the wire. Under
+ /// EVERYTHING ELSE the label is the variable name the server sent, so it is exactly as
+ /// hostile as a value: Escape doubles every bracket, so padding the escaped string pads a
+ /// name containing [ to fewer visible cells than and the
+ /// value column steps left on that row alone. Sanitising it matters for the same reason — MSSP
+ /// says names are upper-case letters and spaces, and a server is not obliged to be truthful.
+ ///
+ ///
+ private static string Row(string label, string value, int cells, string mark = " ") =>
+ $" [{Muted}]{mark}[/] [{Label}]{Escape(Fit(Sanitize(label), NameWidth).PadRight(NameWidth))}[/] "
+ + ScreenChrome.ReadOnly(Fit(Sanitize(value), cells));
+
+ ///
+ /// Replaces every control character with a space. This is the first thing done to any value and it
+ /// is not cosmetic: a markup block is a list of rows, so a raw \n inside one value would end
+ /// that row early and push a fragment of a stranger's text onto a line of its own, below the row it
+ /// belongs to and outside the column it was measured for. An ESC would be worse — the frame
+ /// is ANSI, and the compositor is not the only thing that reads it.
+ ///
+ private static string Sanitize(string value)
+ {
+ if (!value.Any(char.IsControl))
+ {
+ return value;
+ }
+
+ return string.Create(value.Length, value, static (span, source) =>
+ {
+ for (var i = 0; i < source.Length; i++)
+ {
+ span[i] = char.IsControl(source[i]) ? ' ' : source[i];
+ }
+ });
+ }
+
+ ///
+ /// Truncates raw text to cells, ellipsis included in the count. It runs
+ /// before at every call site: escaping doubles every bracket,
+ /// so truncating escaped markup can cut a [[ in half and leave an unbalanced tag the parser
+ /// then eats the rest of the row with.
+ ///
+ private static string Fit(string text, int width) =>
+ text.Length <= width ? text : text[..Math.Max(1, width - 1)] + "…";
+
+ ///
+ /// How long ago something was, in words, with the exact instant beside it. Both halves earn their
+ /// place: "3 days ago" is what tells a reader the player count in front of them is not current, and
+ /// the timestamp is what lets them decide whether it matters. A screen that presented a week-old
+ /// snapshot with no date at all would be reporting stale data as fact.
+ ///
+ private static string Since(DateTimeOffset at, DateTimeOffset now)
+ {
+ var ago = now - at;
+ var words = ago < TimeSpan.Zero ? "just now" : $"{Duration(ago)} ago";
+ return $"{words} ({at.UtcDateTime:yyyy-MM-dd HH:mm} UTC)";
+ }
+
+ /// A coarse duration — the largest unit that is not zero, which is all any of this needs.
+ private static string Duration(TimeSpan span)
+ {
+ if (span < TimeSpan.Zero)
+ {
+ span = TimeSpan.Zero;
+ }
+
+ return span.TotalDays >= 1 ? Plural((int)span.TotalDays, "day")
+ : span.TotalHours >= 1 ? Plural((int)span.TotalHours, "hour")
+ : span.TotalMinutes >= 1 ? Plural((int)span.TotalMinutes, "minute")
+ : "moments";
+ }
+
+ private static string Plural(int count, string noun) =>
+ $"{count.ToString(CultureInfo.InvariantCulture)} {noun}{(count == 1 ? string.Empty : "s")}";
+}
diff --git a/src/SharpMUTerm.Tui/MsspScreenView.cs b/src/SharpMUTerm.Tui/MsspScreenView.cs
new file mode 100644
index 0000000..5249b93
--- /dev/null
+++ b/src/SharpMUTerm.Tui/MsspScreenView.cs
@@ -0,0 +1,64 @@
+using SharpConsoleUI.Builders;
+using SharpConsoleUI.Controls;
+using SharpConsoleUI.Layout;
+using SharpMUTerm.Core.Configuration;
+using SharpMUTerm.Core.Telnet.Mssp;
+
+namespace SharpMUTerm.Tui;
+
+///
+/// Composes 's blocks into the control tree the settings overlay hosts.
+/// One column, not two: this screen is a report rather than a list beside an editor, so there is no
+/// second pane to divide, and the hairline every other screen carries would be dividing nothing.
+///
+internal static class MsspScreenView
+{
+ internal static IWindowControl Build(
+ WorldDefinition? world,
+ MsspObservation? observation,
+ DateTimeOffset now,
+ int width,
+ ScreenFocus? focus = null,
+ int height = 0)
+ {
+ var header = ScreenChrome.Band(MsspScreenRenderer.HeaderLine(width), ScreenPalette.HeaderBg);
+ var footer = ScreenChrome.Band(
+ MsspScreenRenderer.FooterLine(world, observation, focus, width, now), ScreenPalette.FooterBg);
+
+ var rows = ScreenChrome.Rows(height);
+
+ // Rendered once and both used and measured. It was rendered twice — the second call only to
+ // read `.Count` for the row budget — which was wasted work on every layout pass and, worse, a
+ // way for the control's content and the height it was sized for to disagree the moment the
+ // renderer stopped being a pure function of its arguments.
+ var lines = MsspScreenRenderer.Render(world, observation, now, focus, rows, width);
+ var body = ScreenChrome.Stretch(new MarkupControl(lines));
+
+ var root = Controls.Grid()
+ .WithAlignment(HorizontalAlignment.Stretch)
+ .WithVerticalAlignment(VerticalAlignment.Fill);
+
+ if (rows <= 0)
+ {
+ root.Rows(GridLength.Cells(1), GridLength.Star(1), GridLength.Cells(1)).Columns(GridLength.Star(1));
+ root.Place(header, 0, 0, 1, 1);
+ root.Place(body, 1, 0, 1, 1);
+ root.Place(footer, 2, 0, 1, 1);
+ return root.Build();
+ }
+
+ // The body is sized to its content and the slack below it belongs to the backdrop, the same call
+ // ScreenChrome.Split makes: a report of six rows should not be drawn as a thirty-row empty panel
+ // whose emptiness reads as missing data on a screen whose whole subject is what is missing.
+ root.Rows(
+ GridLength.Cells(1),
+ GridLength.Cells(Math.Clamp(lines.Count, 1, rows)),
+ GridLength.Star(1),
+ GridLength.Cells(1))
+ .Columns(GridLength.Star(1));
+ root.Place(header, 0, 0, 1, 1);
+ root.Place(body, 1, 0, 1, 1);
+ root.Place(footer, 3, 0, 1, 1);
+ return root.Build();
+ }
+}
diff --git a/src/SharpMUTerm.Tui/Program.cs b/src/SharpMUTerm.Tui/Program.cs
index 5d1bf82..5e0d17e 100644
--- a/src/SharpMUTerm.Tui/Program.cs
+++ b/src/SharpMUTerm.Tui/Program.cs
@@ -1,5 +1,6 @@
using Microsoft.Extensions.Logging;
using SharpMUTerm.Core.Configuration;
+using SharpMUTerm.Core.Telnet.Mssp;
using SharpMUTerm.Core.Text;
using SharpMUTerm.Graphics;
using SharpConsoleUI.Drivers;
@@ -106,13 +107,28 @@ private static int Main(string[] args)
Logger = diagnostics.For("SharpMUTerm.RestoreLog"),
};
+ // What every server has said about itself, beside the configuration and deliberately not in it:
+ // config.json is what the user asked for and is hand-edited, and a write per connect has no
+ // business landing there. Resolved here for the same reason logRoot and the restore log are —
+ // only this code knows it is the live client. Anything else gets a memory-only cache and so
+ // provably writes nothing.
+ var mssp = new MsspCache(MsspCache.PathFor(ConfigurationStore.DefaultPath))
+ {
+ Logger = diagnostics.For("SharpMUTerm.Mssp"),
+ };
+ if (mssp.Problem is { } msspProblem)
+ {
+ loadLogger.LogWarning("{Notice}", msspProblem);
+ }
+
var liveApp = new SharpMUTermApp(
config,
capabilities,
diagnostics: diagnostics,
save: saved => ConfigurationStore.Save(ConfigurationStore.DefaultPath, saved),
logRoot: logRoot,
- restore: restore);
+ restore: restore,
+ mssp: mssp);
var exitCode = liveApp.Run(startup); // blocks on the SharpConsoleUI main loop until exit
// Persist the workspace so the next launch resumes where this one left off.
@@ -254,6 +270,12 @@ private static void WriteUsage(TextWriter usage)
// password go" should be answerable without reading the source. Config is safe to share; this is not.
usage.WriteLine($"Secrets: {SecretsStore.PathFor(ConfigurationStore.DefaultPath)}"
+ " — character passwords, plain text, owner-only. Not the file to paste.");
+
+ // Named for the same reason: it is a file this client creates in the user's own directory, and
+ // "what is this and can I delete it" should be answerable from the help page. It can: it is a
+ // cache of what servers published, and deleting it costs only the next connection's report.
+ usage.WriteLine($"Servers: {MsspCache.PathFor(ConfigurationStore.DefaultPath)}"
+ + " — each server's last MSSP report (F5 ▸ i). A cache; safe to delete.");
// "why does it connect to *that*?" is the question this setting answers, so the answer belongs
// on the page a user reaches for when they ask it. Both halves are stated: what connects with no
// host, and that a host overrides it.
diff --git a/src/SharpMUTerm.Tui/ScreenChrome.cs b/src/SharpMUTerm.Tui/ScreenChrome.cs
index 593a816..71b4e59 100644
--- a/src/SharpMUTerm.Tui/ScreenChrome.cs
+++ b/src/SharpMUTerm.Tui/ScreenChrome.cs
@@ -26,7 +26,12 @@ internal static class ScreenChrome
///
///
internal static string Hints(
- string verbs, string fkey, bool editable = false, ScreenFocus? focus = null, bool removable = false)
+ string verbs,
+ string fkey,
+ bool editable = false,
+ ScreenFocus? focus = null,
+ bool removable = false,
+ bool detailed = false)
{
if (focus?.Edit is { } edit)
{
@@ -46,7 +51,14 @@ internal static string Hints(
+ $"[{ScreenPalette.Label}] close [/]";
}
- var all = (editable ? verbs + EditHint : verbs) + (removable ? DeleteHint : string.Empty);
+ // Order is a budget, not taste: a header narrower than its hints loses the *tail* of this string
+ // (see the 80-column frames), so whatever is appended last is what a narrow terminal does not
+ // get. `i info` goes after `Del remove` because Delete is the destructive key and must be the
+ // one that survives — and because the INFO key's drawn row (`i info on Aetherfall`) is on the
+ // screen either way, while a key that removes a world has only this line and its own row.
+ var all = (editable ? verbs + EditHint : verbs)
+ + (removable ? DeleteHint : string.Empty)
+ + (detailed ? DetailHint : string.Empty);
return $"[{ScreenPalette.Label}]{all} · [/][{ScreenPalette.Accent}]{fkey}[/][{ScreenPalette.Label}]/[/]"
+ $"[{ScreenPalette.Accent}]Esc[/][{ScreenPalette.Label}] close [/]";
}
@@ -81,6 +93,15 @@ internal static string Hints(
///
internal const string DeleteHint = " · Del remove";
+ ///
+ /// What a screen adds to its hints when — and only when — a pane offers a read-only report on the
+ /// selected row. It matters more than that this is derived rather than
+ /// written: i is an ordinary letter, so a screen that answered it without saying so would be
+ /// a hidden feature, and one that said so without answering it would look broken on the one pane
+ /// where the key does nothing.
+ ///
+ internal const string DetailHint = " · i info";
+
/// The hints that replace a screen's own while a field edit is open.
internal const string EditingHints = "⏎ commit · Esc revert";
@@ -697,9 +718,12 @@ internal static List Buttons(
continue;
}
- lines.Add(button.Kind == ScreenButtonKind.Remove
- ? RemovalRow(button)
- : Cursor(AddRow(button), cursor.IsOn(pane, firstIndex + i), width));
+ lines.Add(button.Kind switch
+ {
+ ScreenButtonKind.Remove => KeyHintRow(button, RemovesWord),
+ ScreenButtonKind.Detail => KeyHintRow(button, InfoWords),
+ _ => Cursor(AddRow(button), cursor.IsOn(pane, firstIndex + i), width),
+ });
}
return lines;
@@ -715,17 +739,21 @@ private static string AddRow(ScreenButton button)
}
///
- /// What Delete would take, as a row. Never drawn with a cursor bar, because the cursor cannot get
- /// there — that is the whole fix for "only the last world can be deleted".
+ /// What a targeted key would act on, as a row: the key, the verb, the victim or subject. Never drawn
+ /// with a cursor bar, because the cursor cannot get there — that is the whole fix for "only the last
+ /// world can be deleted", and the reason the INFO key is drawn this way too rather than as a chip.
///
- private static string RemovalRow(ScreenButton button) =>
+ private static string KeyHintRow(ScreenButton button, string verb) =>
$"[{ScreenPalette.Accent}]{MarkupText.Escape(button.Label)}[/]"
- + $" [{ScreenPalette.Label}]{RemovesWord}[/] "
+ + $" [{ScreenPalette.Label}]{verb}[/] "
+ $"[{ScreenPalette.Value}]{MarkupText.Escape(button.Target ?? string.Empty)}[/]";
/// The verb on a removal row, between the key and what it would take.
internal const string RemovesWord = "removes";
+ /// The verb on a report row, between the key and what it would report on.
+ internal const string InfoWords = "info on";
+
///
/// Where the cursor is within one of a screen's lists — trigger 1/4, world 2/2. Every
/// footer's context line opens with one of these, so the eight screens answer the same question in
diff --git a/src/SharpMUTerm.Tui/ScreenModel.cs b/src/SharpMUTerm.Tui/ScreenModel.cs
index 2f7b5c7..163dc5a 100644
--- a/src/SharpMUTerm.Tui/ScreenModel.cs
+++ b/src/SharpMUTerm.Tui/ScreenModel.cs
@@ -118,6 +118,38 @@ internal static ScreenButton Add(
target);
}
+ ///
+ /// What a read-only detail screen's key is drawn as, for the same reason
+ /// is: the row naming it is not a cursor stop, so a chip would be an
+ /// affordance for something the keyboard cannot land on.
+ ///
+ internal const string DetailKeyLabel = "i";
+
+ ///
+ /// Opens a read-only report about the selected row. It is a so that the
+ /// row the screen draws and the key that runs it come from one place, exactly as a removal does —
+ /// but it changes nothing, so it returns no undo and moves no cursor, and
+ /// runs it outside . Navigation is
+ /// not an edit: routing it through the edit log would persist the configuration and re-periodise
+ /// every running timer every time somebody looked at a world.
+ ///
+ /// The row this would report on, named on the drawn key-hint row.
+ /// Puts the report on the screen.
+ internal static ScreenButton Detail(string target, Action open)
+ {
+ ArgumentNullException.ThrowIfNull(open);
+
+ return new ScreenButton(
+ DetailKeyLabel,
+ () =>
+ {
+ open();
+ return new ScreenPress(null);
+ },
+ ScreenButtonKind.Detail,
+ target);
+ }
+
///
/// Removes the item at , restoring it *at that index* on undo. The cursor
/// stays on the same ordinal, which is now whatever followed the deleted row — the same place the
@@ -173,6 +205,16 @@ internal enum ScreenButtonKind
/// Aetherfall) rather than as a chip, because it is not a cursor stop — see .
///
Remove,
+
+ ///
+ /// Opens a read-only report about the selected row (i info on Aetherfall), changing nothing.
+ /// It is drawn and navigated exactly as a removal is, and for the identical reason: the action has a
+ /// target, and an action with a target must not steal the cursor from the thing it acts on
+ /// (). A chip walked to with ↑↓ would drag the selection to the last
+ /// row of the list, so an INFO chip could only ever report on the last world — the same defect as
+ /// "only the last world can be deleted", one feature later.
+ ///
+ Detail,
}
///
@@ -270,16 +312,22 @@ internal ScreenModel(params IReadOnlyList[] panes)
internal IReadOnlyList Sizes { get; }
///
- /// How many of a pane's rows the cursor may occupy: all of them but the destructive buttons at the
- /// end. Removals are appended last on every screen (a pane reads list → add → duplicate → remove), so
- /// this is a count and not a set of holes — the cursor never has to skip a row in the middle, and
- /// stays a plain clamp. pins that
- /// the shape really is that way on all eight screens.
+ /// How many of a pane's rows the cursor may occupy: all of them but the targeted buttons at
+ /// the end. Those are appended last on every screen (a pane reads list → add → duplicate → info →
+ /// remove), so this is a count and not a set of holes — the cursor never has to skip a row in the
+ /// middle, and stays a plain clamp. ScreenModelTests pins the
+ /// count against the drawn rows, and MsspScreenTests.TheInfoRowIsDrawnAndIsNotSomewhereTheCursorCanGo
+ /// pins it for the half.
+ ///
+ /// Both non-stop kinds must stay trailing. A row put
+ /// anywhere but among them gives the cursor a hole, and the clamp becomes a skip list.
+ ///
///
private static int Stops(IReadOnlyList rows)
{
var count = rows.Count;
- while (count > 0 && rows[count - 1].Button is { Kind: ScreenButtonKind.Remove })
+ while (count > 0
+ && rows[count - 1].Button is { Kind: ScreenButtonKind.Remove or ScreenButtonKind.Detail })
{
count--;
}
@@ -446,6 +494,53 @@ internal bool HasRemovableRow
return null;
}
+ ///
+ /// Whether any pane offers a read-only report on the selected row. The i info hint is derived
+ /// from this exactly as Del remove is derived from : a screen
+ /// physically cannot advertise a letter key it does not answer, which matters more here than for
+ /// Delete because i is a letter and a screen that swallowed it silently would be indis-
+ /// tinguishable from one where the key was simply not wired.
+ ///
+ internal bool HasDetailRow
+ {
+ get
+ {
+ for (var pane = 0; pane < _panes.Length; pane++)
+ {
+ if (DetailIn(pane) is not null)
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+
+ ///
+ /// A pane's read-only report button, or null when it has none — what i runs while the cursor
+ /// is on one of that pane's list rows. The same shape as , and scoped the same
+ /// way: a pane that does not offer the button does not answer the key, which is what keeps i
+ /// from meaning anything in a pane that has editable fields.
+ ///
+ internal ScreenButton? DetailIn(int pane)
+ {
+ if (pane < 0 || pane >= _panes.Length)
+ {
+ return null;
+ }
+
+ foreach (var row in _panes[pane])
+ {
+ if (row.Button is { Kind: ScreenButtonKind.Detail } button)
+ {
+ return button;
+ }
+ }
+
+ return null;
+ }
+
///
/// Whether a pane position is one of its *list* rows rather than one of the buttons appended after
/// them. Delete asks this before acting: on a button row the cursor already has ⏎, and deleting the
diff --git a/src/SharpMUTerm.Tui/SettingsOverlay.cs b/src/SharpMUTerm.Tui/SettingsOverlay.cs
index d3ecafa..7139d94 100644
--- a/src/SharpMUTerm.Tui/SettingsOverlay.cs
+++ b/src/SharpMUTerm.Tui/SettingsOverlay.cs
@@ -34,6 +34,21 @@ internal sealed class SettingsOverlay
private ConsoleKey _openKey;
private ScreenBinding? _binding;
+ ///
+ /// What Esc goes back to. A read-only report opened from a screen (F5's INFO) pushes the screen it
+ /// came from here and replaces the content of the same window; Esc pops it and the screen
+ /// is rebuilt from its own untouched , so the cursor is on the world it
+ /// was on. It is a stack rather than a single slot only because a stack cannot be got wrong by a
+ /// later report that opens a report.
+ ///
+ /// Content-swapping, not window-stacking, and that is the same call
+ /// made for the same reason: two modal windows with two
+ /// PreviewKeyPressed handlers cannot be driven headlessly, so a second window here would put
+ /// this screen outside every snapshot and every test in the suite.
+ ///
+ ///
+ private readonly Stack _behind = new();
+
///
/// The overlay no longer takes a save action. Persistence moved to the point of change: each screen's
/// writes the configuration out as it accepts one, so there is no moment on
@@ -84,6 +99,26 @@ public void Toggle(ConsoleKey key, Func binding)
/// Renders a screen into a headless frame (used by snapshots).
public void OpenForSnapshot(ConsoleKey key, ScreenBinding binding) => Open(key, binding);
+ ///
+ /// Puts a read-only report over the screen that asked for it, keeping that screen alive behind it so
+ /// Esc comes back to the row it was on. Does nothing when no screen is open — a report about a
+ /// selection that is not on the screen has nothing to be about.
+ ///
+ public void OpenDetail(ScreenBinding binding)
+ {
+ if (_window is null || _binding is not { } current)
+ {
+ return;
+ }
+
+ _behind.Push(current);
+ _binding = binding;
+ Refresh();
+ }
+
+ /// Whether a report is open over a screen — which is what makes Esc mean back.
+ internal bool IsShowingDetail => _behind.Count > 0;
+
///
/// Feeds one key to the open screen through the very handler PreviewKeyPressed raises, so a
/// snapshot can show a screen in a state only the keyboard can reach (a field mid-edit) without
@@ -188,6 +223,14 @@ private bool Apply(ScreenBinding binding, ScreenAction action)
{
switch (action)
{
+ case ScreenAction.Close when _behind.Count > 0:
+ // Esc out of a report goes back to the screen it was opened from, not out of the
+ // settings altogether. The screen behind is its own live session, so it comes back with
+ // its cursor, its open selection and its edit log intact — the report never touched them.
+ _binding = _behind.Pop();
+ Refresh();
+ return true;
+
case ScreenAction.Close:
CloseAndReview();
return true;
@@ -251,7 +294,10 @@ private void Refresh()
///
private void CloseAndReview()
{
- var edits = _binding?.Session.Edits;
+ // The edits to review are the *screen's*, which is at the bottom of the stack when a report is
+ // over it: a report has an empty edit log of its own, so reading the top would silently drop a
+ // deletion made just before someone pressed i.
+ var edits = (_behind.Count > 0 ? _behind.Last() : _binding)?.Session.Edits;
Close();
if (edits is { HasDeletions: true })
@@ -276,5 +322,6 @@ private void Reset()
_system.ConsoleDriver.Paste -= OnPaste;
_window = null;
_binding = null;
+ _behind.Clear();
}
}
diff --git a/src/SharpMUTerm.Tui/SettingsSession.cs b/src/SharpMUTerm.Tui/SettingsSession.cs
index 3d2e41e..fdd4e8e 100644
--- a/src/SharpMUTerm.Tui/SettingsSession.cs
+++ b/src/SharpMUTerm.Tui/SettingsSession.cs
@@ -232,6 +232,9 @@ private ScreenAction Interpret(ConsoleKeyInfo key)
case ConsoleKey.Delete:
return Remove(model);
+ case ConsoleKey.I when key.Modifiers == 0:
+ return Detail(model);
+
case ConsoleKey.Spacebar:
return Toggle(model);
@@ -273,6 +276,40 @@ private ScreenAction Remove(ScreenModel model)
return ScreenAction.Redraw;
}
+ ///
+ /// i on one of a pane's list rows opens that pane's read-only report on the selected row —
+ /// F5's INFO. It is the shape with two deliberate differences.
+ ///
+ /// It does not go through . Every other button press is an edit and is
+ /// persisted the moment it is accepted; this one changes nothing, and routing it through the edit
+ /// log would write config.json and re-periodise every running timer each time somebody looked
+ /// at a world. Opening a screen is navigation.
+ ///
+ ///
+ /// It returns , not . The
+ /// report has already replaced what is on screen by the time this returns; a redraw here would
+ /// rebuild the screen we have just navigated away from, over the one we navigated to.
+ ///
+ ///
+ /// A plain letter is only safe as a command because it is scoped by
+ /// to a pane that offers one — and because has already handed the whole
+ /// keyboard to an open field edit several branches above this. Both halves are load-bearing: without
+ /// the first, i would be swallowed in the CHARACTERS pane where nothing answers it; without
+ /// the second it could not be typed into a world's name.
+ ///
+ ///
+ private ScreenAction Detail(ScreenModel model)
+ {
+ if (!model.IsListRow(Selection.Pane, Selection.Index)
+ || model.DetailIn(Selection.Pane) is not { } button)
+ {
+ return ScreenAction.None;
+ }
+
+ button.Run();
+ return ScreenAction.Consumed;
+ }
+
private ScreenAction Toggle(ScreenModel model)
{
if (model.ToggleAt(Selection.Pane, Selection.Index) is not { } toggle)
diff --git a/src/SharpMUTerm.Tui/SharpMUTermApp.cs b/src/SharpMUTerm.Tui/SharpMUTermApp.cs
index 1cd74f5..8acd555 100644
--- a/src/SharpMUTerm.Tui/SharpMUTermApp.cs
+++ b/src/SharpMUTerm.Tui/SharpMUTermApp.cs
@@ -8,6 +8,7 @@
using SharpMUTerm.Core.Logging;
using SharpMUTerm.Core.Session;
using SharpMUTerm.Core.Telnet;
+using SharpMUTerm.Core.Telnet.Mssp;
using SharpMUTerm.Core.Text;
using SharpMUTerm.Core.Transport;
using SharpMUTerm.Core.Theming;
@@ -223,6 +224,13 @@ private sealed class SizeReport
private readonly CommandPalette _palette;
private readonly SettingsOverlay _settings;
+ ///
+ /// The settings overlay, so a headless test can drive a key into an open screen and ask what
+ /// happened. It is the same seam SimulateKey exists for and for the same reason: the
+ /// framework only pumps input inside Run(), which no test enters.
+ ///
+ internal SettingsOverlay Settings => _settings;
+
/// The ⌃P ▸ Show client messages viewer over the diagnostics log.
private readonly MessageLogOverlay _messageLog;
@@ -326,6 +334,16 @@ private sealed class SizeReport
///
private readonly RestoreLog? _restore;
+ ///
+ /// Every server's last MSSP report, which is what the F5 ▸ i INFO screen reads. Never null,
+ /// and that is the difference from the three above rather than an inconsistency with them: a cache
+ /// built with no path is memory-only by construction — it reads nothing and writes nothing —
+ /// so the guarantee those three get from a null check, this one gets from the object it is handed.
+ /// A structural guarantee beats a check at every use site, and there is exactly one use site here
+ /// that a check could be forgotten at anyway. Only Program hands one a path.
+ ///
+ private readonly MsspCache _mssp;
+
/// The pane the live mouse drag is hovering, and the edge it would split — null when idle.
private string? _dragTargetPaneId;
private Edge? _dragEdge;
@@ -388,6 +406,14 @@ private sealed class SizeReport
/// has no business creating those. It is also what keeps the demo scene honest — a
/// --demo-config snapshot would otherwise restore your panes into the demo's.
///
+ ///
+ /// Where each server's MSSP report is kept between launches, or null for a memory-only cache — which
+ /// is the default, and is what every test and every snapshot gets. It differs from the three
+ /// parameters above in one way that matters: the *field* is never null, because a memory-only
+ /// is a working cache that happens to own no file. The INFO screen therefore
+ /// needs no "is there a cache" branch, and a snapshot can seed a report through the same writer a
+ /// live session uses without any of it reaching disk.
+ ///
public SharpMUTermApp(
AppConfiguration config,
TerminalCapabilities capabilities,
@@ -396,12 +422,14 @@ public SharpMUTermApp(
ClientDiagnostics? diagnostics = null,
Action? save = null,
string? logRoot = null,
- RestoreLog? restore = null)
+ RestoreLog? restore = null,
+ MsspCache? mssp = null)
{
_config = config;
_save = save;
_logRoot = string.IsNullOrWhiteSpace(logRoot) ? null : logRoot;
_restore = restore;
+ _mssp = mssp ?? new MsspCache();
_capabilities = capabilities;
_time = time ?? TimeProvider.System;
_diagnostics = diagnostics ?? ClientDiagnostics.InMemory();
@@ -966,6 +994,32 @@ public string RenderSnapshot(string? view = null)
_settings.SimulateKey(Stroke('\0', ConsoleKey.Escape));
}
+ // The MSSP report, reached the only way a user can reach it: open F5 and press `i` on the
+ // selected world. Nothing about the screen is faked here — the key runs the real button, which
+ // opens the real binding over the real overlay.
+ //
+ // Three views because the screen has three states and two of them are empty ones that must not
+ // look alike. `mssp` seeds a report through the live writer; `mssp-none` only records a
+ // connection, which is the "connected and this server publishes no MSSP" arm; `mssp-never`
+ // seeds nothing at all, which is the arm a world you have not dialled is in.
+ if (view is not null && view.StartsWith(MsspScreenRenderer.View, StringComparison.OrdinalIgnoreCase))
+ {
+ var world = _config.Worlds.Count > 0 ? _config.Worlds[0] : null;
+ if (world is not null && !view.EndsWith("-never", StringComparison.OrdinalIgnoreCase))
+ {
+ _mssp.RecordConnection(world.Host, world.Port, _time.GetUtcNow());
+ if (!view.EndsWith("-none", StringComparison.OrdinalIgnoreCase))
+ {
+ CaptureMssp(world, DemoScene.MsspReport());
+ }
+ }
+
+ _settings.OpenForSnapshot(ConsoleKey.F5, WorldsScreen());
+ _settings.SimulateKey(Stroke('i', ConsoleKey.I));
+ SyncInputWidth();
+ return RenderFrame();
+ }
+
// Settings screens (composed-control or markup — SettingsView hands back a control factory
// either way) open over the workspace for their --view name. A "-edit" view opens the
// same screen and then drives real keys into it, so the frame shows a field genuinely mid-edit
@@ -1633,10 +1687,24 @@ private void BindSession(WorldSession session, string? windowId = null)
if (e.State == ConnectionState.Connected)
{
ReportAutomation(session);
+
+ // Recorded on the *connection*, not on the report — because "we reached this server and
+ // it published nothing" is a fact the INFO screen has to be able to state, and it is
+ // only ever knowable from the connection having happened. MSSP arrives on the read loop
+ // after this transition, so a server that does publish overwrites nothing: it adds a
+ // report beside a connection time that is already here.
+ _mssp.RecordConnection(session.World.Host, session.World.Port, _time.GetUtcNow());
}
UpdateStatus();
});
+
+ // MSSP is captured per world and kept, so the report is readable while nothing is connected —
+ // which is when it is wanted, since the question the screen answers is "what is this world"
+ // asked before or between sessions. It goes through CaptureMssp rather than straight into the
+ // cache so the snapshot's demo report is written by the same code the wire is (see DemoScene's
+ // remarks on state a live session writes).
+ session.MsspReceived += (_, e) => OnUi(() => CaptureMssp(session.World, e.Data));
session.GmcpReceived += (_, e) => OnUi(() =>
{
if (_stats.Update(e.Package, e.Json))
@@ -3812,6 +3880,54 @@ private LoggingSettings ActiveLogging()
/// character's trigger sets → the selected world's security checkboxes), seeded on whatever is
/// connected so the screen opens where the user already is.
///
+ ///
+ /// Files one server's MSSP report under the world it came from. The one writer: the live
+ /// subscription in and the snapshot's demo report both go through it, so
+ /// the endpoint a frame is rendered from and the endpoint a connection files under cannot be
+ /// different strings — the gap that has hidden three separate bugs in the demo scene already.
+ ///
+ internal void CaptureMssp(WorldDefinition world, MsspData report)
+ {
+ ArgumentNullException.ThrowIfNull(world);
+ _mssp.RecordReport(world.Host, world.Port, report, _time.GetUtcNow());
+ }
+
+ ///
+ /// Opens the read-only MSSP report for a world, over the screen that asked for it. Bounds-checked
+ /// against the live list rather than trusted, because the index was captured when the WORLDS pane's
+ /// buttons were built and a keystroke between then and now could have deleted the row.
+ ///
+ private void OpenMsspScreen(int world)
+ {
+ if (world < 0 || world >= _config.Worlds.Count)
+ {
+ return;
+ }
+
+ _settings.OpenDetail(MsspScreen(_config.Worlds[world]));
+ }
+
+ ///
+ /// The MSSP report screen for one world. A full like every other screen
+ /// — the same session, the same key table, the same overlay — because that is what gives it
+ /// scrolling, a cursor and headless key simulation for nothing. Its model offers no fields, no
+ /// toggles and no removals, so the header derives none of those hints.
+ ///
+ private ScreenBinding MsspScreen(WorldDefinition world)
+ {
+ var observation = _mssp.Find(world.Host, world.Port);
+ var session = new SettingsSession(_ => MsspScreenRenderer.Model(
+ world, observation, _time.GetUtcNow(), _system.DesktopDimensions.Width));
+
+ return new ScreenBinding(session, () => MsspScreenView.Build(
+ world,
+ observation,
+ _time.GetUtcNow(),
+ _system.DesktopDimensions.Width,
+ session.Focus(),
+ _system.DesktopDimensions.Height));
+ }
+
private ScreenBinding WorldsScreen() => WorldsScreen(WorldsScreenRenderer.FKey, onCharacters: false);
///
@@ -3838,7 +3954,8 @@ private ScreenBinding WorldsScreen(string fkey, bool onCharacters)
_config.TriggerSets,
selection.SelectionIn(WorldsScreenRenderer.WorldsPane),
selection.SelectionIn(WorldsScreenRenderer.CharactersPane),
- selection.SelectionIn(WorldsScreenRenderer.TriggerSetsPane)),
+ selection.SelectionIn(WorldsScreenRenderer.TriggerSetsPane),
+ OpenMsspScreen),
SaveConfiguration);
session.Selection.Seed(WorldsScreenRenderer.WorldsPane, ActiveWorldIndex());
session.Selection.Seed(WorldsScreenRenderer.CharactersPane, ActiveCharacterIndex());
@@ -3856,7 +3973,8 @@ private ScreenBinding WorldsScreen(string fkey, bool onCharacters)
session.Focus(),
fkey,
session.Selection.SelectionIn(WorldsScreenRenderer.TriggerSetsPane),
- _system.DesktopDimensions.Height));
+ _system.DesktopDimensions.Height,
+ info: true));
}
///
diff --git a/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs b/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs
index 68cca39..f32100b 100644
--- a/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs
+++ b/src/SharpMUTerm.Tui/WorldsScreenRenderer.cs
@@ -237,7 +237,12 @@ internal static string HeaderLine(
{
var title = $"[bold {Value}] Worlds & Characters[/]";
var hints = ScreenChrome.Hints(
- ScreenChrome.ListHints, fkey, model?.HasEditableRow ?? false, focus, model?.HasRemovableRow ?? false);
+ ScreenChrome.ListHints,
+ fkey,
+ model?.HasEditableRow ?? false,
+ focus,
+ model?.HasRemovableRow ?? false,
+ model?.HasDetailRow ?? false);
return SpreadLR(" " + title, hints, width);
}
@@ -363,12 +368,20 @@ internal static string HeaderLine(
/// the first, the way every other pane's cursor starts on its first row, so a caller that only wants
/// the navigable shape still gets the pane's real buttons.
///
+ ///
+ /// Opens the read-only MSSP report for the world at the given index, or null when this projection has
+ /// nowhere to open one — which is every caller but the live app: the renderer is pure and a screen is
+ /// not something a markup block can put on the screen by itself. Null means the WORLDS pane grows no
+ /// i row, so the header hint (derived from ) does not
+ /// advertise a key that would do nothing.
+ ///
internal static ScreenModel Model(
IReadOnlyList worlds,
IReadOnlyList triggerSets,
int selectedWorld,
int selectedCharacter,
- int selectedSet = 0)
+ int selectedSet = 0,
+ Action? info = null)
{
ArgumentNullException.ThrowIfNull(worlds);
ArgumentNullException.ThrowIfNull(triggerSets);
@@ -381,7 +394,7 @@ 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)))
- .Concat(WorldButtons(worlds, selectedWorld))
+ .Concat(WorldButtons(worlds, selectedWorld, info))
.ToArray();
var world = selectedWorld >= 0 && selectedWorld < worlds.Count ? worlds[selectedWorld] : null;
@@ -634,11 +647,20 @@ private static ScreenRow[] SecurityRows(WorldDefinition world) => new[]
};
///
- /// The WORLDS list's buttons. Deleting is offered only when there is a world under the cursor to
- /// delete; a brand-new world is a blank template, because a world's whole identity is its host and
- /// a "helpfully" prefilled one would be a guess the user then has to notice and undo.
+ /// The WORLDS pane's buttons, in the order they are drawn and — decisively — in the order
+ /// needs them: the cursor stop first, then the two targeted key
+ /// hints. [+ world] has no target and so needs somewhere to be pressed from; i and
+ /// Del both act on the selected row and must not steal the cursor from it, so they trail and
+ /// are trimmed out of the pane's stop count. Put either of them above [+ world] and the
+ /// cursor gains a hole.
+ ///
+ /// Both targeted keys are offered only when there is a world under the cursor for them to act on. A
+ /// brand-new world is a blank template, because a world's whole identity is its host and a
+ /// "helpfully" prefilled one would be a guess the user then has to notice and undo.
+ ///
///
- private static List WorldButtons(IReadOnlyList worlds, int selectedWorld)
+ private static List WorldButtons(
+ IReadOnlyList worlds, int selectedWorld, Action? info = null)
{
var rows = new List();
// Arrays report IsReadOnly through IList, and a renderer handed one (the unit tests, any
@@ -652,6 +674,14 @@ private static List WorldButtons(IReadOnlyList world
if (selectedWorld >= 0 && selectedWorld < list.Count)
{
var world = list[selectedWorld];
+ if (info is not null)
+ {
+ // The index is captured, not the world: the report is opened against whatever the WORLDS
+ // list holds at that position when the key is pressed, which is the row the cursor is on.
+ var at = selectedWorld;
+ rows.Add(ScreenRow.Of(ScreenButton.Detail(world.Name, () => info(at))));
+ }
+
rows.Add(ScreenRow.Of(ScreenButton.Remove(
list, selectedWorld, target: world.Name, describe: () => DescribeWorld(world))));
}
@@ -726,7 +756,11 @@ internal static string FooterLine(
/// was never drawn, which is precisely the failure exists to stop.
///
internal static List WorldsColumn(
- IReadOnlyList worlds, int selectedWorld, ScreenFocus? focus = null, int height = 0)
+ IReadOnlyList worlds,
+ int selectedWorld,
+ ScreenFocus? focus = null,
+ int height = 0,
+ bool info = false)
{
var cursor = focus ?? ScreenFocus.None;
selectedWorld = Selected(worlds.Count, selectedWorld);
@@ -756,8 +790,16 @@ internal static List WorldsColumn(
}
left.Add(string.Empty);
+ // The drawn rows come from the same WorldButtons the model navigates by, so the row saying what
+ // `i` acts on and the button the key runs cannot name different worlds. The action itself is not
+ // needed to draw it — only whether there is one — so the column takes a bool rather than the
+ // delegate, which keeps the renderer pure.
left.AddRange(ScreenChrome.Buttons(
- WorldButtons(worlds, selectedWorld), cursor, WorldsPane, worlds.Count, LeftColumnWidth));
+ WorldButtons(worlds, selectedWorld, info ? _ => { } : null),
+ cursor,
+ WorldsPane,
+ worlds.Count,
+ LeftColumnWidth));
// Compacted, then windowed — the same two steps the detail column takes, and it must be both:
// dropping the blank separators buys back a row per world, and the window is what guarantees the
diff --git a/src/SharpMUTerm.Tui/WorldsScreenView.cs b/src/SharpMUTerm.Tui/WorldsScreenView.cs
index 6efd489..508c382 100644
--- a/src/SharpMUTerm.Tui/WorldsScreenView.cs
+++ b/src/SharpMUTerm.Tui/WorldsScreenView.cs
@@ -25,7 +25,8 @@ public static IWindowControl Build(
ScreenFocus? focus = null,
string fkey = WorldsScreenRenderer.FKey,
int selectedSet = 0,
- int height = 0)
+ int height = 0,
+ bool info = false)
{
// Both panes end in button rows, so a raw cursor can point past its list; resolving once here
// keeps every block of the screen agreeing on which world and character are selected.
@@ -33,8 +34,10 @@ public static IWindowControl Build(
WorldsScreenRenderer.Resolve(worlds, selectedWorld, selectedCharacter);
var accent = WorldsScreenRenderer.AccentFor(worlds, selectedWorld);
+ // The model is rebuilt here only to derive the header's hints, so the INFO row's presence is all
+ // it needs of the action — a no-op stands in for it, and the delegate stays with the session.
var model = WorldsScreenRenderer.Model(
- worlds, triggerSets, selectedWorld, selectedCharacter, selectedSet);
+ worlds, triggerSets, selectedWorld, selectedCharacter, selectedSet, info ? _ => { } : null);
var header = ScreenChrome.Band(
WorldsScreenRenderer.HeaderLine(width, model, focus, fkey), ScreenPalette.HeaderBg);
var footer = ScreenChrome.Band(
@@ -60,7 +63,7 @@ public static IWindowControl Build(
// Body: WORLDS list │ detail, as two real columns.
var worldsCol = ScreenChrome.Stretch(new MarkupControl(
- WorldsScreenRenderer.WorldsColumn(worlds, selectedWorld, focus, rows).ToList()));
+ WorldsScreenRenderer.WorldsColumn(worlds, selectedWorld, focus, rows, info).ToList()));
var detailCol = ScreenChrome.Stretch(new MarkupControl(
WorldsScreenRenderer
.DetailColumn(worlds, triggerSets, selectedWorld, selectedCharacter, accent, focus, rows)
diff --git a/tests/SharpMUTerm.Core.Tests/Telnet/MsspCacheTests.cs b/tests/SharpMUTerm.Core.Tests/Telnet/MsspCacheTests.cs
new file mode 100644
index 0000000..ef6edf6
--- /dev/null
+++ b/tests/SharpMUTerm.Core.Tests/Telnet/MsspCacheTests.cs
@@ -0,0 +1,388 @@
+using System.Globalization;
+using SharpMUTerm.Core.Configuration;
+using SharpMUTerm.Core.Telnet.Mssp;
+
+namespace SharpMUTerm.Core.Tests.Telnet;
+
+///
+/// What the INFO screen reads: which endpoint a report is filed under, how the three states are told
+/// apart, what a second connection does to the first one's report, and what a hostile server gets to
+/// put on disk.
+///
+/// Nothing here touches ConfigurationStore.DefaultPath. Every case either uses a memory-only
+/// cache or a temporary directory of its own — the developer's own configuration is not a fixture.
+///
+///
+public class MsspCacheTests
+{
+ private static readonly DateTimeOffset Noon = new(2026, 7, 30, 12, 0, 0, TimeSpan.Zero);
+
+ private static MsspData Report(params (string Variable, string[] Values)[] entries) =>
+ MsspWire.Report(entries);
+
+ /// A directory of its own, deleted afterwards; never created until something writes.
+ private sealed class TempRoot : IDisposable
+ {
+ public TempRoot() =>
+ Root = Path.Combine(Path.GetTempPath(), $"smuterm-mssp-{Guid.NewGuid():N}");
+
+ public string Root { get; }
+
+ /// One level deeper, so the store has a folder to create the way the real one does.
+ public string ConfigPath => Path.Combine(Root, "SharpMUTerm", "config.json");
+
+ public string CachePath => MsspCache.PathFor(ConfigPath);
+
+ public void Dispose()
+ {
+ try
+ {
+ if (Directory.Exists(Root))
+ {
+ Directory.Delete(Root, recursive: true);
+ }
+ }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException)
+ {
+ // A temp directory that will not go is not this test's business.
+ }
+ }
+ }
+
+ // ---- Identity ----
+
+ [Test]
+ public async Task ACacheFileSitsBesideTheConfigurationAndItsSecrets()
+ {
+ var config = Path.Combine(Path.GetTempPath(), "nowhere", "SharpMUTerm", "config.json");
+
+ await Assert.That(MsspCache.PathFor(config))
+ .IsEqualTo(Path.Combine(Path.GetTempPath(), "nowhere", "SharpMUTerm", MsspCache.FileName));
+ await Assert.That(Path.GetDirectoryName(MsspCache.PathFor(config)))
+ .IsEqualTo(Path.GetDirectoryName(SecretsStore.PathFor(config)));
+ }
+
+ [Test]
+ public async Task OneServerIsOneEntryHoweverItsHostIsSpelled()
+ {
+ // The folding is what stops a server accumulating a half-filled entry per spelling its worlds
+ // happen to use. A trailing root dot and a capital are the same host to DNS and must be here.
+ var cache = new MsspCache();
+ cache.RecordReport("MUD.Example.ORG.", 4201, Report(("NAME", ["One"])), Noon);
+ cache.RecordReport("mud.example.org", 4201, Report(("NAME", ["Two"])), Noon);
+
+ await Assert.That(cache.All).Count().IsEqualTo(1);
+ await Assert.That(cache.Find("MUD.EXAMPLE.ORG", 4201)!.Report!.Name).IsEqualTo("Two");
+ }
+
+ [Test]
+ public async Task TwoPortsOnOneHostAreTwoServers()
+ {
+ // The plaintext port and the TLS port are two endpoints and may run two games. Keying by host
+ // alone would have one report standing for both, which is the one reading a user cannot correct.
+ var cache = new MsspCache();
+ cache.RecordReport("mud.example.org", 4000, Report(("NAME", ["Plain"])), Noon);
+ cache.RecordReport("mud.example.org", 4001, Report(("NAME", ["Secure"])), Noon);
+
+ await Assert.That(cache.Find("mud.example.org", 4000)!.Report!.Name).IsEqualTo("Plain");
+ await Assert.That(cache.Find("mud.example.org", 4001)!.Report!.Name).IsEqualTo("Secure");
+ }
+
+ // ---- The three states ----
+
+ [Test]
+ public async Task AnEndpointNothingHasReachedHasNoEntryAtAll()
+ {
+ await Assert.That(new MsspCache().Find("never.example.org", 4000)).IsNull();
+ }
+
+ [Test]
+ public async Task AConnectionWithNoReportIsRecordedAsExactlyThat()
+ {
+ // The state the whole two-timestamp design exists for: we spoke to this server and it published
+ // nothing. It must be distinguishable from never having spoken to it, and it is — there is an
+ // observation, and its report is null.
+ var cache = new MsspCache();
+ cache.RecordConnection("quiet.example.org", 4000, Noon);
+
+ var observation = cache.Find("quiet.example.org", 4000);
+ await Assert.That(observation).IsNotNull();
+ await Assert.That(observation!.PublishesNothing).IsTrue();
+ await Assert.That(observation.ObservedAt).IsNull();
+ await Assert.That(observation.ConnectedAt).IsEqualTo(Noon);
+ }
+
+ [Test]
+ public async Task AReportKeepsTheConnectionTimeItArrivedUnder()
+ {
+ var cache = new MsspCache();
+ cache.RecordConnection("mud.example.org", 4000, Noon);
+ cache.RecordReport("mud.example.org", 4000, Report(("NAME", ["Corvid"])), Noon.AddSeconds(2));
+
+ var observation = cache.Find("mud.example.org", 4000)!;
+ await Assert.That(observation.ConnectedAt).IsEqualTo(Noon);
+ await Assert.That(observation.ObservedAt).IsEqualTo(Noon.AddSeconds(2));
+ await Assert.That(observation.PublishesNothing).IsFalse();
+ }
+
+ [Test]
+ public async Task AServerThatStopsPublishingKeepsTheReportItLastGave()
+ {
+ // Its date moves on and the report's does not, which is the point: the screen dates the report,
+ // so a stale player count is labelled stale rather than blanked or re-dated. Clearing it on the
+ // next connect would throw away the only answer we have in order to be freshly ignorant.
+ var cache = new MsspCache();
+ cache.RecordReport("mud.example.org", 4000, Report(("PLAYERS", ["17"])), Noon);
+ cache.RecordConnection("mud.example.org", 4000, Noon.AddDays(30));
+
+ var observation = cache.Find("mud.example.org", 4000)!;
+ await Assert.That(observation.Report!.Players).IsEqualTo(17);
+ await Assert.That(observation.ObservedAt).IsEqualTo(Noon);
+ await Assert.That(observation.ConnectedAt).IsEqualTo(Noon.AddDays(30));
+ }
+
+ // ---- A second report ----
+
+ [Test]
+ public async Task ASecondReportReplacesTheFirstRatherThanMergingWithIt()
+ {
+ // MSSP is not a delta protocol: a server sends its whole table once per connection. A merge
+ // would keep a variable it has stopped publishing for ever — the accumulating-room-exits failure
+ // in a different costume — and would leave a report that is a snapshot of no moment that existed.
+ var cache = new MsspCache();
+ cache.RecordReport(
+ "mud.example.org", 4000, Report(("NAME", ["Old"]), ("DISCORD", ["https://old"])), Noon);
+ cache.RecordReport("mud.example.org", 4000, Report(("NAME", ["New"])), Noon.AddDays(1));
+
+ var report = cache.Find("mud.example.org", 4000)!.Report!;
+ await Assert.That(report.Name).IsEqualTo("New");
+ await Assert.That(report.ContainsKey("DISCORD")).IsFalse();
+ await Assert.That(cache.Find("mud.example.org", 4000)!.ObservedAt).IsEqualTo(Noon.AddDays(1));
+ }
+
+ [Test]
+ public async Task AnEmptyReportIsStillAReportAndStillReplaces()
+ {
+ // A server that negotiates MSSP and sends no variables has said something: it publishes, and it
+ // publishes nothing in particular. That is not the same as never having answered.
+ var cache = new MsspCache();
+ cache.RecordReport("mud.example.org", 4000, Report(("NAME", ["Was"])), Noon);
+ cache.RecordReport("mud.example.org", 4000, MsspData.Empty, Noon.AddDays(1));
+
+ var observation = cache.Find("mud.example.org", 4000)!;
+ await Assert.That(observation.PublishesNothing).IsFalse();
+ await Assert.That(observation.Report!.Count).IsEqualTo(0);
+ }
+
+ // ---- Bounds ----
+
+ [Test]
+ public async Task AHostileReportIsCutDownBeforeItIsStored()
+ {
+ var cache = new MsspCache();
+ var flood = Enumerable.Range(0, MsspCache.MaxVariables + 50)
+ .Select(i => ($"VAR{i}", new[] { new string('x', MsspCache.MaxValueLength * 4) }))
+ .Append(("PORT", Enumerable.Range(0, MsspCache.MaxValuesPerVariable + 20)
+ .Select(i => i.ToString(System.Globalization.CultureInfo.InvariantCulture))
+ .ToArray()))
+ .ToArray();
+
+ cache.RecordReport("hostile.example.org", 4000, Report(flood), Noon);
+ var report = cache.Find("hostile.example.org", 4000)!.Report!;
+
+ await Assert.That(report.Count).IsEqualTo(MsspCache.MaxVariables);
+ await Assert.That(report["VAR0"][0].Length).IsEqualTo(MsspCache.MaxValueLength);
+ // PORT was appended past the variable cap, so it is not kept at all — the head of what the
+ // server sent survives, which is a subset a reader can reason about.
+ await Assert.That(report.ContainsKey("PORT")).IsFalse();
+ }
+
+ [Test]
+ public async Task AMultiValuedVariableInsideTheCapKeepsEveryValueInOrder()
+ {
+ var cache = new MsspCache();
+ cache.RecordReport("mud.example.org", 4000, Report(("PORT", ["80", "23", "4201"])), Noon);
+
+ await Assert.That(cache.Find("mud.example.org", 4000)!.Report!["PORT"])
+ .IsEquivalentTo(new[] { "80", "23", "4201" });
+ }
+
+ [Test]
+ public async Task TheOldestEndpointsAreDroppedOnceTheFileIsFull()
+ {
+ var cache = new MsspCache();
+ for (var i = 0; i < MsspCache.MaxEndpoints + 10; i++)
+ {
+ cache.RecordConnection($"host{i}.example.org", 4000, Noon.AddMinutes(i));
+ }
+
+ await Assert.That(cache.All).Count().IsEqualTo(MsspCache.MaxEndpoints);
+ await Assert.That(cache.Find("host0.example.org", 4000)).IsNull();
+ await Assert.That(cache.Find($"host{MsspCache.MaxEndpoints + 9}.example.org", 4000)).IsNotNull();
+ }
+
+ // ---- Disk ----
+
+ [Test]
+ public async Task ACacheWithNoPathWritesNothingAnywhere()
+ {
+ // This is the guarantee a snapshot and every test in the suite runs on, and it is structural:
+ // there is no file to write to, rather than a check at each use site that could be forgotten.
+ using var temp = new TempRoot();
+ var cache = new MsspCache();
+ cache.RecordReport("mud.example.org", 4000, Report(("NAME", ["Corvid"])), Noon);
+
+ await Assert.That(cache.IsPersistent).IsFalse();
+ await Assert.That(Directory.Exists(temp.Root)).IsFalse();
+ }
+
+ [Test]
+ public async Task AReportSurvivesARestartWithItsValuesAndTheirOrderIntact()
+ {
+ using var temp = new TempRoot();
+ var writer = new MsspCache(temp.CachePath);
+ writer.RecordConnection("mud.example.org", 4201, Noon);
+ writer.RecordReport(
+ "mud.example.org",
+ 4201,
+ Report(("NAME", ["Corvid Nest"]), ("PORT", ["80", "23", "4201"]), ("VANITY", ["least", "most"])),
+ Noon.AddSeconds(1));
+
+ var reread = new MsspCache(temp.CachePath);
+ var observation = reread.Find("mud.example.org", 4201)!;
+
+ await Assert.That(reread.Problem).IsNull();
+ await Assert.That(observation.ConnectedAt).IsEqualTo(Noon);
+ await Assert.That(observation.ObservedAt).IsEqualTo(Noon.AddSeconds(1));
+ await Assert.That(observation.Report!.Name).IsEqualTo("Corvid Nest");
+ await Assert.That(observation.Report["PORT"]).IsEquivalentTo(new[] { "80", "23", "4201" });
+
+ // Order is meaning in MSSP — "least to most relevant" — so the on-disk form is an array and the
+ // wire order has to come back the way it went in, not however a JSON object happened to keep it.
+ await Assert.That(observation.Report.Keys).IsEquivalentTo(new[] { "NAME", "PORT", "VANITY" });
+ }
+
+ [Test]
+ public async Task AConnectionWithNoReportSurvivesARestartAsThatStateAndNotAsNothing()
+ {
+ using var temp = new TempRoot();
+ new MsspCache(temp.CachePath).RecordConnection("quiet.example.org", 4000, Noon);
+
+ var observation = new MsspCache(temp.CachePath).Find("quiet.example.org", 4000);
+ await Assert.That(observation).IsNotNull();
+ await Assert.That(observation!.PublishesNothing).IsTrue();
+ }
+
+ [Test]
+ public async Task AnUnreadableCacheStartsEmptyAndSaysSoInsteadOfThrowing()
+ {
+ using var temp = new TempRoot();
+ Directory.CreateDirectory(Path.GetDirectoryName(temp.CachePath)!);
+ File.WriteAllText(temp.CachePath, "{ this is not json");
+
+ var cache = new MsspCache(temp.CachePath);
+ await Assert.That(cache.All).IsEmpty();
+ await Assert.That(cache.Problem).IsNotNull();
+ }
+
+ [Test]
+ public async Task ACacheFromANewerSchemaIsIgnoredRatherThanMisread()
+ {
+ using var temp = new TempRoot();
+ Directory.CreateDirectory(Path.GetDirectoryName(temp.CachePath)!);
+ File.WriteAllText(temp.CachePath, """{ "version": 99, "servers": { "a:1": {} } }""");
+
+ var cache = new MsspCache(temp.CachePath);
+ await Assert.That(cache.All).IsEmpty();
+ await Assert.That(cache.Problem).IsNotNull();
+ }
+
+ [Test]
+ public async Task AnEntryWrittenUnderAnUnnormalisedKeyIsStillFindable()
+ {
+ // Persist writes normalised keys, so a self-written file is always fine — but this file sits
+ // beside config.json and a hand-written (or future, or foreign) entry spelled `MUD.Example.ORG:4201`
+ // would be filed under a key Key() can never produce: permanently unreachable through Find while
+ // still spending the endpoint budget. Re-key on the way in, never trust the property name.
+ using var temp = new TempRoot();
+ Directory.CreateDirectory(Path.GetDirectoryName(temp.CachePath)!);
+ File.WriteAllText(temp.CachePath, """
+ {
+ "version": 1,
+ "servers": {
+ "MUD.Example.ORG.:4201": { "connectedAt": "2026-07-30T12:00:00+00:00" },
+ "not-an-endpoint": { "connectedAt": "2026-07-30T12:00:00+00:00" }
+ }
+ }
+ """);
+
+ var cache = new MsspCache(temp.CachePath);
+
+ await Assert.That(cache.Find("mud.example.org", 4201)).IsNotNull();
+ await Assert.That(cache.All).Count().IsEqualTo(1);
+ await Assert.That(cache.Problem).IsNotNull().Because("the portless entry was skipped");
+ }
+
+ [Test]
+ public async Task ABloatedFileIsBoundedOnTheWayInAndNotOnlyOnTheNextWrite()
+ {
+ // MaxEndpoints was enforced in Persist alone, so a file grown past it was fully materialised at
+ // startup and trimmed only if something later wrote — which is precisely the launch where the
+ // bound was wanted.
+ using var temp = new TempRoot();
+ Directory.CreateDirectory(Path.GetDirectoryName(temp.CachePath)!);
+ var entries = string.Join(
+ ",\n",
+ Enumerable.Range(0, MaxEndpointsOverflow).Select(i =>
+ " \"host" + i.ToString(CultureInfo.InvariantCulture)
+ + ".example.org:4000\": { \"connectedAt\": \"2026-07-30T12:00:00+00:00\" }"));
+ File.WriteAllText(temp.CachePath, "{ \"version\": 1, \"servers\": {\n" + entries + "\n} }");
+
+ await Assert.That(new MsspCache(temp.CachePath).All).Count().IsEqualTo(MsspCache.MaxEndpoints);
+ }
+
+ private const int MaxEndpointsOverflow = MsspCache.MaxEndpoints + 25;
+
+ [Test]
+ public async Task AnEntryOfTheWrongJsonKindIsSkippedRatherThanThrown()
+ {
+ // This file sits beside config.json, which people hand-edit, so a number where a string belongs
+ // is a thing that will happen. JsonNode.GetValue() *throws* on one — and a throw here is
+ // a throw out of the constructor, on the startup path, over a cache.
+ using var temp = new TempRoot();
+ Directory.CreateDirectory(Path.GetDirectoryName(temp.CachePath)!);
+ File.WriteAllText(temp.CachePath, """
+ {
+ "version": "one",
+ "servers": {
+ "a:1": { "connectedAt": 12345 },
+ "b:2": { "connectedAt": "2026-07-30T12:00:00+00:00", "observedAt": true,
+ "variables": [ { "name": 7, "values": [1, "ok"] } ] }
+ }
+ }
+ """);
+
+ var cache = new MsspCache(temp.CachePath);
+
+ await Assert.That(cache.Find("a", 1)).IsNull();
+ var b = cache.Find("b", 2);
+ await Assert.That(b).IsNotNull();
+ await Assert.That(b!.PublishesNothing).IsTrue();
+ }
+
+ [Test]
+ public async Task OneUnreadableEntryDoesNotCostTheRest()
+ {
+ using var temp = new TempRoot();
+ var writer = new MsspCache(temp.CachePath);
+ writer.RecordConnection("good.example.org", 4000, Noon);
+
+ var text = File.ReadAllText(temp.CachePath)
+ .Replace("\"servers\": {", "\"servers\": {\n \"bad:1\": { \"connectedAt\": \"not a date\" },");
+ File.WriteAllText(temp.CachePath, text);
+
+ var cache = new MsspCache(temp.CachePath);
+ await Assert.That(cache.Find("good.example.org", 4000)).IsNotNull();
+ await Assert.That(cache.Problem).IsNotNull();
+ }
+}
diff --git a/tests/SharpMUTerm.Crawler.Tests/MsspParsingTests.cs b/tests/SharpMUTerm.Core.Tests/Telnet/MsspParsingTests.cs
similarity index 83%
rename from tests/SharpMUTerm.Crawler.Tests/MsspParsingTests.cs
rename to tests/SharpMUTerm.Core.Tests/Telnet/MsspParsingTests.cs
index 94dc989..fad51ce 100644
--- a/tests/SharpMUTerm.Crawler.Tests/MsspParsingTests.cs
+++ b/tests/SharpMUTerm.Core.Tests/Telnet/MsspParsingTests.cs
@@ -1,36 +1,35 @@
using System.Text;
+using SharpMUTerm.Core.Telnet;
using SharpMUTerm.Core.Telnet.Mssp;
-using SharpMUTerm.Crawler.Model;
-using SharpMUTerm.Crawler.Probing;
-using SharpMUTerm.Crawler.Tests.Support;
using TelnetNegotiationCore.Models;
-namespace SharpMUTerm.Crawler.Tests;
+namespace SharpMUTerm.Core.Tests.Telnet;
///
/// What a server's MSSP report turns into, against payloads built byte by byte from the
-/// specification's own format and fed through a real telnet session.
+/// specification's own format and fed through a real .
///
-/// There is no MSSP parser in this repository any more. These cases used to pin one, written
-/// because TelnetNegotiationCore's reader destroyed arrays, booleans and unknown variables before any
+/// There is no MSSP parser in this repository. These cases used to pin one, written because
+/// TelnetNegotiationCore's reader destroyed arrays, booleans and unknown variables before any
/// consumer saw them. That is fixed upstream (2.6.5), so the same cases now run against the library's
/// own MSSPConfig.Variables by way of , and prove the replacement keeps
-/// what the workaround kept. The two that diverge are named as such and say why.
+/// what the workaround kept. The two that diverge from the old behaviour are named as such
+/// and say why.
+///
+///
+/// The session is built the way a world's is —
+/// carrying , so the client asks rather than waits — and
+/// the scripted server answers only a client that asked. These are therefore the pins for the INFO
+/// screen's supply as much as for the model.
///
///
public class MsspParsingTests
{
- private static MsspHost Host => MsspHost.Create("server.example.org", 4201)!;
-
- private static CrawlOptions Options => new()
- {
- ConnectTimeout = TimeSpan.FromSeconds(5),
- MsspTimeout = TimeSpan.FromSeconds(5),
- };
+ private static readonly TimeSpan Patience = TimeSpan.FromSeconds(5);
///
- /// One server, one report: a scripted server offers MSSP, answers the crawler's DO with
- /// , and the report the probe came back with is returned.
+ /// One server, one report: a scripted server offers MSSP, answers the client's DO with
+ /// , and the report the session raised is returned.
///
private static Task Read(params (string Variable, string[] Values)[] entries) =>
ReadRaw(MsspWire.Subnegotiation(entries));
@@ -40,11 +39,28 @@ private static async Task ReadRaw(byte[] payload, bool fragmented = fa
var transport = new ScriptedTransport { Greeting = MsspWire.Offer(), Fragmented = fragmented }
.RespondingToDo(MsspWire.Mssp, payload);
- var result = await new TelnetMsspProbe(Options, _ => transport).ProbeAsync(Host, CancellationToken.None);
+ return await ReadFrom(transport)
+ ?? throw new InvalidOperationException("The session raised no MSSP report.");
+ }
- return result.Outcome == CrawlOutcome.MsspReceived && result.Data is { } data
- ? data
- : throw new InvalidOperationException($"No MSSP report: {result.Outcome} ({result.Error}).");
+ ///
+ /// Connects a real session to and returns the first MSSP report it
+ /// raises, or null when none arrives inside . Null is a result here
+ /// rather than a failure: "this server publishes no MSSP" is one of the three states the INFO
+ /// screen has to tell apart, and one case below has that as its whole subject.
+ ///
+ private static async Task ReadFrom(ScriptedTransport transport, TimeSpan? patience = null)
+ {
+ var received = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ await using var session = new TelnetSession(
+ transport,
+ options: new TelnetSessionOptions { RequestOptions = [TelnetSessionOptions.MsspOption] });
+ session.MsspReceived += (_, e) => received.TrySetResult(e.Data);
+
+ await session.ConnectAsync();
+
+ var completed = await Task.WhenAny(received.Task, Task.Delay(patience ?? Patience));
+ return completed == received.Task ? await received.Task : null;
}
[Test]
@@ -77,11 +93,11 @@ public async Task ArrayNotationKeepsEveryValueInOrderWithTheDefaultLast()
[Test]
public async Task TheReferralListArrivesAsAList()
{
- // REFERRAL is array-only and is what a crawler follows; 2.6.0 delivered it as null.
+ // REFERRAL is array-only; 2.6.0 delivered it as null. Nothing reads it by name any more — the
+ // INFO screen renders it among the rest — so what must survive is the array, whole and in order.
var data = await Read(("REFERRAL", ["a.example.org 4000", "b.example.net 23", "2001:db8::5 4201"]));
- await Assert.That(data["REFERRAL"]).Count().IsEqualTo(3);
- await Assert.That(data.Referrals.Select(r => r.ToReferralString()))
+ await Assert.That(data["REFERRAL"])
.IsEquivalentTo(new[] { "a.example.org 4000", "b.example.net 23", "2001:db8::5 4201" });
}
@@ -184,16 +200,7 @@ public async Task VariableNamesAreMatchedWithoutRegardToCaseOrStrayWhitespace()
var data = await Read((" crawl delay ", ["11"]));
await Assert.That(data.ContainsKey("CRAWL DELAY")).IsTrue();
- await Assert.That(data.CrawlDelay).IsEqualTo(TimeSpan.FromHours(11));
- }
-
- [Test]
- public async Task ACrawlDelayOfMinusOneMeansNoPreferenceRatherThanANegativeInterval()
- {
- // "Send -1 to use the crawler's default." The library's own Integer() hands -1 back as-is,
- // deliberately; the reading that a scheduler can use is this projection's.
- await Assert.That((await Read(("CRAWL DELAY", ["-1"]))).CrawlDelay).IsNull();
- await Assert.That((await Read(("CRAWL DELAY", ["23"]))).CrawlDelay).IsEqualTo(TimeSpan.FromHours(23));
+ await Assert.That(data.Default("crawl_delay")).IsEqualTo("11");
}
[Test]
@@ -246,7 +253,7 @@ public async Task APayloadSplitAcrossReadsParsesTheSameAsAWholeOne()
await Assert.That(data.Name).IsEqualTo("Corvid Nest");
await Assert.That(data.Ports).IsEquivalentTo(new[] { 80, 23, 4201 });
- await Assert.That(data.Referrals.Single().ToReferralString()).IsEqualTo("a.example.org 4000");
+ await Assert.That(data["REFERRAL"]).IsEquivalentTo(new[] { "a.example.org 4000" });
}
[Test]
@@ -290,12 +297,7 @@ public async Task AnUnterminatedPayloadYieldsNothingRatherThanAPartialReport()
var transport = new ScriptedTransport { Greeting = MsspWire.Offer() }
.RespondingToDo(MsspWire.Mssp, whole.AsSpan(0, whole.Length - 2).ToArray());
- var result = await new TelnetMsspProbe(
- Options with { MsspTimeout = TimeSpan.FromMilliseconds(400) }, _ => transport)
- .ProbeAsync(Host, CancellationToken.None);
-
- await Assert.That(result.Outcome).IsEqualTo(CrawlOutcome.NoMssp);
- await Assert.That(result.Data).IsNull();
+ await Assert.That(await ReadFrom(transport, TimeSpan.FromMilliseconds(400))).IsNull();
}
[Test]
diff --git a/tests/SharpMUTerm.Crawler.Tests/Support/MsspWire.cs b/tests/SharpMUTerm.Core.Tests/Telnet/MsspWire.cs
similarity index 91%
rename from tests/SharpMUTerm.Crawler.Tests/Support/MsspWire.cs
rename to tests/SharpMUTerm.Core.Tests/Telnet/MsspWire.cs
index 046764a..af81d01 100644
--- a/tests/SharpMUTerm.Crawler.Tests/Support/MsspWire.cs
+++ b/tests/SharpMUTerm.Core.Tests/Telnet/MsspWire.cs
@@ -1,7 +1,7 @@
using System.Text;
using SharpMUTerm.Core.Telnet.Mssp;
-namespace SharpMUTerm.Crawler.Tests.Support;
+namespace SharpMUTerm.Core.Tests.Telnet;
///
/// Builds MSSP subnegotiations byte by byte, exactly as the specification spells them, so a test
@@ -46,9 +46,9 @@ public static byte[] Subnegotiation(params (string Variable, string[] Values)[]
///
/// The same entries as an , without a socket. For the tests whose subject is
- /// what the crawler does with a report — scheduling, referral following, persistence —
- /// rather than how the report was read. Reading it is the telnet layer's job and is pinned, once,
- /// by MsspParsingTests driving a real session.
+ /// what something does with a report — caching it, rendering it — rather than how it was
+ /// read. Reading it is the telnet layer's job and is pinned, once, by MsspParsingTests
+ /// driving a real session.
///
public static MsspData Report(params (string Variable, string[] Values)[] entries) =>
MsspData.From(entries.Select(entry =>
diff --git a/tests/SharpMUTerm.Crawler.Tests/Support/ScriptedTransport.cs b/tests/SharpMUTerm.Core.Tests/Telnet/ScriptedTransport.cs
similarity index 98%
rename from tests/SharpMUTerm.Crawler.Tests/Support/ScriptedTransport.cs
rename to tests/SharpMUTerm.Core.Tests/Telnet/ScriptedTransport.cs
index a6d4876..2629a6c 100644
--- a/tests/SharpMUTerm.Crawler.Tests/Support/ScriptedTransport.cs
+++ b/tests/SharpMUTerm.Core.Tests/Telnet/ScriptedTransport.cs
@@ -1,7 +1,7 @@
using System.Threading.Channels;
using SharpMUTerm.Core.Transport;
-namespace SharpMUTerm.Crawler.Tests.Support;
+namespace SharpMUTerm.Core.Tests.Telnet;
///
/// An in-memory transport standing in for a server: queue bytes to be delivered, capture everything
@@ -10,7 +10,7 @@ namespace SharpMUTerm.Crawler.Tests.Support;
/// This is a *server*, not just a pipe. The MSSP handshake is a conversation — the server says
/// IAC WILL MSSP and only sends its report once the client has answered IAC DO MSSP — so
/// a transport that replayed a fixed script would test the telnet layer's reader against bytes no real
-/// server would have sent yet, and would never exercise the negotiation the crawler depends on.
+/// server would have sent yet, and would never exercise the negotiation the client depends on.
///
///
internal sealed class ScriptedTransport : ITransport
diff --git a/tests/SharpMUTerm.Crawler.Tests/BackoffTests.cs b/tests/SharpMUTerm.Crawler.Tests/BackoffTests.cs
deleted file mode 100644
index 1ad12eb..0000000
--- a/tests/SharpMUTerm.Crawler.Tests/BackoffTests.cs
+++ /dev/null
@@ -1,179 +0,0 @@
-using SharpMUTerm.Core.Telnet.Mssp;
-using SharpMUTerm.Crawler.Model;
-using SharpMUTerm.Crawler.Scheduling;
-using SharpMUTerm.Crawler.Tests.Support;
-
-namespace SharpMUTerm.Crawler.Tests;
-
-///
-/// What the crawler remembers about a host that failed, and when it is willing to try again.
-///
-public class BackoffTests
-{
- private static readonly DateTimeOffset Now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
-
- private static MsspHost Host(string name = "a.example.org", int port = 4201) => MsspHost.Create(name, port)!;
-
- private static ProbeResult Result(MsspHost host, CrawlOutcome outcome, MsspData? data = null, DateTimeOffset? at = null) =>
- new()
- {
- Host = host,
- Outcome = outcome,
- ObservedAt = at ?? Now,
- Data = data,
- Error = outcome is CrawlOutcome.ConnectFailed ? "ConnectionRefused" : null,
- };
-
- private static (CrawlFrontier Frontier, MsspHost Host) Seeded(CrawlOptions? options = null)
- {
- var frontier = new CrawlFrontier(options ?? new CrawlOptions());
- var host = Host();
- frontier.AddSeed(host, Now);
- return (frontier, host);
- }
-
- [Test]
- public async Task ARefusedConnectionIsRecordedAndNotRetriedImmediately()
- {
- var (frontier, host) = Seeded();
-
- frontier.Record(Result(host, CrawlOutcome.ConnectFailed));
- var record = frontier.Records.Single();
-
- await Assert.That(record.ConsecutiveFailures).IsEqualTo(1);
- await Assert.That(record.LastOutcome).IsEqualTo(CrawlOutcome.ConnectFailed);
- await Assert.That(record.LastError).IsEqualTo("ConnectionRefused");
- await Assert.That(record.NotBefore).IsEqualTo(Now + TimeSpan.FromHours(1));
- await Assert.That(record.IsDue(Now)).IsFalse();
- await Assert.That(record.IsDue(Now + TimeSpan.FromHours(1))).IsTrue();
-
- // And the frontier will not hand it out again while it is not due.
- await Assert.That(frontier.TakeNext(Now)).IsNull();
- }
-
- [Test]
- public async Task EachFurtherFailureWaitsLonger()
- {
- var (frontier, host) = Seeded();
- var expected = new[] { 1d, 6d, 24d, 72d };
-
- var at = Now;
- for (var attempt = 0; attempt < expected.Length; attempt++)
- {
- frontier.Record(Result(host, CrawlOutcome.ConnectFailed, at: at));
- var record = frontier.Records.Single();
- await Assert.That(record.NotBefore).IsEqualTo(at + TimeSpan.FromHours(expected[attempt]));
-
- at = record.NotBefore!.Value;
- // Re-claim it, the way the crawl loop does, so the next Record has something to release.
- frontier.TakeNext(at);
- }
- }
-
- [Test]
- public async Task RepeatedFailuresRetireAHostEntirely()
- {
- var (frontier, host) = Seeded(new CrawlOptions { RetireAfterFailures = 3 });
-
- for (var i = 0; i < 3; i++)
- {
- frontier.Record(Result(host, CrawlOutcome.ConnectFailed));
- frontier.TakeNext(Now + TimeSpan.FromDays(30));
- }
-
- var record = frontier.Records.Single();
- await Assert.That(record.Retired).IsTrue();
- await Assert.That(record.IsDue(Now + TimeSpan.FromDays(365))).IsFalse();
-
- // Retired means retired: the frontier never offers it again, however long a run waits.
- await Assert.That(frontier.TakeNext(Now + TimeSpan.FromDays(365))).IsNull();
- }
-
- [Test]
- public async Task OneSuccessClearsTheFailureCount()
- {
- var (frontier, host) = Seeded();
-
- frontier.Record(Result(host, CrawlOutcome.ConnectFailed));
- frontier.TakeNext(Now + TimeSpan.FromHours(2));
- frontier.Record(Result(host, CrawlOutcome.MsspReceived, MsspData.Empty, Now + TimeSpan.FromHours(2)));
-
- var record = frontier.Records.Single();
- await Assert.That(record.ConsecutiveFailures).IsEqualTo(0);
- await Assert.That(record.LastSuccess).IsEqualTo(Now + TimeSpan.FromHours(2));
- }
-
- [Test]
- public async Task AServerWithNoMsspIsNotAFailureButWaitsMuchLonger()
- {
- // Most MU* servers do not implement MSSP. That is not a fault of theirs and must not retire
- // them, but asking again tomorrow would be pure cost to both sides.
- var (frontier, host) = Seeded();
-
- frontier.Record(Result(host, CrawlOutcome.NoMssp));
- var record = frontier.Records.Single();
-
- await Assert.That(record.ConsecutiveFailures).IsEqualTo(0);
- await Assert.That(record.Retired).IsFalse();
- await Assert.That(record.NotBefore).IsEqualTo(Now + TimeSpan.FromDays(7));
- }
-
- [Test]
- public async Task ASuccessfulCrawlSetsTheRevisitInterval()
- {
- var (frontier, host) = Seeded();
-
- frontier.Record(Result(host, CrawlOutcome.MsspReceived, MsspData.Empty));
- var record = frontier.Records.Single();
-
- await Assert.That(record.NotBefore).IsEqualTo(Now + TimeSpan.FromHours(24));
- await Assert.That(record.IsDue(Now + TimeSpan.FromHours(23))).IsFalse();
- await Assert.That(record.IsDue(Now + TimeSpan.FromHours(24))).IsTrue();
- }
-
- [Test]
- public async Task AServerAskingForALongerCrawlDelayGetsIt()
- {
- // "CRAWL DELAY — Preferred minimum number of hours between crawls." A server asking for more
- // than our default is asking politely and is obeyed.
- var options = new CrawlOptions { RevisitInterval = TimeSpan.FromHours(6) };
- var (frontier, host) = Seeded(options);
-
- var data = MsspWire.Report(("CRAWL DELAY", ["23"]));
-
- frontier.Record(Result(host, CrawlOutcome.MsspReceived, data));
-
- await Assert.That(frontier.Records.Single().NotBefore).IsEqualTo(Now + TimeSpan.FromHours(23));
- await Assert.That(frontier.Records.Single().CrawlDelayHours).IsEqualTo(23);
- }
-
- [Test]
- public async Task AServerAskingForAShorterCrawlDelayDoesNotGetVisitedMoreOften()
- {
- // It is a minimum a server asks for, not a permission it grants. Our own politeness setting is
- // not theirs to lower.
- var options = new CrawlOptions { RevisitInterval = TimeSpan.FromHours(24) };
- var (frontier, host) = Seeded(options);
-
- var data = MsspWire.Report(("CRAWL DELAY", ["1"]));
-
- frontier.Record(Result(host, CrawlOutcome.MsspReceived, data));
-
- await Assert.That(frontier.Records.Single().NotBefore).IsEqualTo(Now + TimeSpan.FromHours(24));
- }
-
- [Test]
- public async Task ADryRunDoesNotMoveTheSchedule()
- {
- // Checking a configuration must not silently postpone the crawl it was checking.
- var (frontier, host) = Seeded();
-
- frontier.Record(Result(host, CrawlOutcome.Skipped));
- var record = frontier.Records.Single();
-
- await Assert.That(record.Attempts).IsEqualTo(0);
- await Assert.That(record.LastAttempt).IsNull();
- await Assert.That(record.NotBefore).IsNull();
- await Assert.That(record.IsDue(Now)).IsTrue();
- }
-}
diff --git a/tests/SharpMUTerm.Crawler.Tests/CrawlLoopTests.cs b/tests/SharpMUTerm.Crawler.Tests/CrawlLoopTests.cs
deleted file mode 100644
index 187ff02..0000000
--- a/tests/SharpMUTerm.Crawler.Tests/CrawlLoopTests.cs
+++ /dev/null
@@ -1,360 +0,0 @@
-using SharpMUTerm.Core.Telnet.Mssp;
-using SharpMUTerm.Crawler.Model;
-using SharpMUTerm.Crawler.Output;
-using SharpMUTerm.Crawler.Scheduling;
-using SharpMUTerm.Crawler.Tests.Support;
-
-namespace SharpMUTerm.Crawler.Tests;
-
-///
-/// The crawl loop, against a scripted probe. Time is virtual throughout — the loop really does wait on
-/// its rate limiter, and here waiting moves the clock instead of the thread.
-///
-public class CrawlLoopTests
-{
- private static MsspHost Host(string name, int port = 4201) => MsspHost.Create(name, port)!;
-
- /// A configuration with the politeness intervals at zero, for tests that are not about them.
- private static CrawlOptions Fast(CrawlOptions? from = null) => (from ?? new CrawlOptions()) with
- {
- GlobalInterval = TimeSpan.Zero,
- PerHostInterval = TimeSpan.Zero,
- MaxConcurrency = 1,
- };
-
- [Test]
- public async Task ACrawlFollowsReferralsOutwardsFromItsSeed()
- {
- var time = new VirtualTimeProvider();
- var a = Host("a.example.org");
- var b = Host("b.example.org");
- var c = Host("c.example.org");
-
- var probe = new FakeProbe(time).Referring(a, b).Referring(b, c);
- var options = Fast();
- var frontier = new CrawlFrontier(options);
- frontier.AddSeed(a, time.GetUtcNow());
-
- var summary = await new MsspCrawler(options, probe, frontier, time).RunAsync();
-
- await Assert.That(probe.Visited).IsEquivalentTo(new[] { a, b, c });
- await Assert.That(summary.StopReason).IsEqualTo(CrawlStopReason.Exhausted);
- await Assert.That(frontier.Records.Count).IsEqualTo(3);
- }
-
- [Test]
- public async Task ACycleOfReferralsVisitsEachServerExactlyOnce()
- {
- // A ↔ B and both pointing at C, which points back at A. Every arrow is followed, no host is
- // dialled twice, and the run ends.
- var time = new VirtualTimeProvider();
- var a = Host("a.example.org");
- var b = Host("b.example.org");
- var c = Host("c.example.org");
-
- var probe = new FakeProbe(time)
- .Referring(a, b, c)
- .Referring(b, a, c)
- .Referring(c, a, b);
-
- var options = Fast();
- var frontier = new CrawlFrontier(options);
- frontier.AddSeed(a, time.GetUtcNow());
-
- var summary = await new MsspCrawler(options, probe, frontier, time).RunAsync();
-
- await Assert.That(probe.Visited.Count).IsEqualTo(3);
- await Assert.That(probe.Visited.Distinct().Count()).IsEqualTo(3);
- await Assert.That(summary.StopReason).IsEqualTo(CrawlStopReason.Exhausted);
- await Assert.That(summary.Verdicts[DiscoveryVerdict.AlreadyKnown]).IsGreaterThan(0);
- }
-
- [Test]
- public async Task TheHostCapStopsTheRunEvenWithReferralsLeftToFollow()
- {
- // Each server refers to two more, so the frontier grows faster than it is consumed and only the
- // cap can end this.
- var time = new VirtualTimeProvider();
- var probe = new FakeProbe(time);
- for (var i = 0; i < 200; i++)
- {
- probe.Referring(Host($"h{i}.example.org"), Host($"h{i * 2 + 1}.example.org"), Host($"h{i * 2 + 2}.example.org"));
- }
-
- var options = Fast() with { MaxHosts = 7, MaxDepth = 99 };
- var frontier = new CrawlFrontier(options);
- frontier.AddSeed(Host("h0.example.org"), time.GetUtcNow());
-
- var summary = await new MsspCrawler(options, probe, frontier, time).RunAsync();
-
- await Assert.That(summary.StopReason).IsEqualTo(CrawlStopReason.HostCap);
- await Assert.That(probe.Visited.Count).IsEqualTo(7);
- await Assert.That(summary.Contacted).IsEqualTo(7);
-
- // The frontier still knows about the ones it did not reach, so the next run picks them up.
- await Assert.That(frontier.Records.Count).IsGreaterThan(7);
- }
-
- [Test]
- public async Task TheHostCapIsNeverExceededEvenWhenSeveralWorkersRace()
- {
- var time = new VirtualTimeProvider();
- var probe = new FakeProbe(time);
- for (var i = 0; i < 200; i++)
- {
- probe.Referring(Host($"h{i}.example.org"), Host($"h{i * 2 + 1}.example.org"), Host($"h{i * 2 + 2}.example.org"));
- }
-
- var options = Fast() with { MaxHosts = 10, MaxDepth = 99, MaxConcurrency = 8 };
- var frontier = new CrawlFrontier(options);
- frontier.AddSeed(Host("h0.example.org"), time.GetUtcNow());
-
- await new MsspCrawler(options, probe, frontier, time).RunAsync();
-
- await Assert.That(probe.Visited.Count).IsEqualTo(10);
- }
-
- [Test]
- public async Task ACapDoesNotAbortAConnectionAlreadyInFlight()
- {
- // Found by watching a real run: the cap used to cancel the crawl's token, which killed the probes
- // already talking to a server. The data was thrown away, the server was left with a connection
- // dropped mid-handshake, and the host was recorded as never attempted — so the next run dialled
- // it again. A cap must stop the crawl starting anything new and nothing else.
- var time = new VirtualTimeProvider();
- var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- var probe = new FakeProbe(time) { Gate = gate };
- for (var i = 0; i < 50; i++)
- {
- probe.Referring(Host($"h{i}.example.org"), Host($"h{i * 2 + 1}.example.org"), Host($"h{i * 2 + 2}.example.org"));
- }
-
- var options = Fast() with { MaxHosts = 3, MaxDepth = 99, MaxConcurrency = 3 };
- var frontier = new CrawlFrontier(options);
- for (var i = 0; i < 3; i++)
- {
- frontier.AddSeed(Host($"h{i}.example.org"), time.GetUtcNow());
- }
-
- var run = new MsspCrawler(options, probe, frontier, time).RunAsync();
-
- // All three workers are inside a probe when the cap is reached.
- await Task.Delay(150);
- gate.SetResult();
- var summary = await run;
-
- await Assert.That(summary.StopReason).IsEqualTo(CrawlStopReason.HostCap);
- await Assert.That(probe.Visited.Count).IsEqualTo(3);
-
- // Every probe that started was allowed to finish and was recorded as a real observation.
- await Assert.That(summary.Results.Count).IsEqualTo(3);
- await Assert.That(summary.Results.All(r => r.Outcome == CrawlOutcome.MsspReceived)).IsTrue();
- await Assert.That(summary.Contacted).IsEqualTo(3);
- }
-
- [Test]
- public async Task TheDepthCapStopsAChainOfReferrals()
- {
- var time = new VirtualTimeProvider();
- var probe = new FakeProbe(time);
- for (var i = 0; i < 20; i++)
- {
- probe.Referring(Host($"h{i}.example.org"), Host($"h{i + 1}.example.org"));
- }
-
- var options = Fast() with { MaxDepth = 2, MaxHosts = 1000 };
- var frontier = new CrawlFrontier(options);
- frontier.AddSeed(Host("h0.example.org"), time.GetUtcNow());
-
- var summary = await new MsspCrawler(options, probe, frontier, time).RunAsync();
-
- // The seed plus two hops.
- await Assert.That(probe.Visited.Count).IsEqualTo(3);
- await Assert.That(summary.StopReason).IsEqualTo(CrawlStopReason.Exhausted);
- await Assert.That(summary.Verdicts[DiscoveryVerdict.TooDeep]).IsEqualTo(1);
- }
-
- [Test]
- public async Task TheTimeCapStopsTheRun()
- {
- // With a one-second global interval and a ten-second budget, the rate limiter alone runs the
- // clock out. Virtual time makes that instant and exact.
- var time = new VirtualTimeProvider();
- var probe = new FakeProbe(time);
- for (var i = 0; i < 500; i++)
- {
- probe.Referring(Host($"h{i}.example.org"), Host($"h{i * 2 + 1}.example.org"), Host($"h{i * 2 + 2}.example.org"));
- }
-
- var options = new CrawlOptions
- {
- GlobalInterval = TimeSpan.FromSeconds(1),
- PerHostInterval = TimeSpan.Zero,
- MaxConcurrency = 1,
- MaxDuration = TimeSpan.FromSeconds(10),
- MaxHosts = 10_000,
- MaxDepth = 99,
- };
-
- var frontier = new CrawlFrontier(options);
- frontier.AddSeed(Host("h0.example.org"), time.GetUtcNow());
-
- var started = time.GetUtcNow();
- var summary = await new MsspCrawler(options, probe, frontier, time).RunAsync();
-
- await Assert.That(summary.StopReason).IsEqualTo(CrawlStopReason.TimeCap);
- await Assert.That(time.GetUtcNow() - started).IsGreaterThanOrEqualTo(TimeSpan.FromSeconds(10));
-
- // Roughly one host per second of budget, which is the rate limiter doing its job inside the loop
- // rather than the loop merely being told about it.
- await Assert.That(probe.Visited.Count).IsLessThanOrEqualTo(12);
- }
-
- [Test]
- public async Task TheConcurrencyCapIsRespected()
- {
- var time = new VirtualTimeProvider();
- var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
- var probe = new FakeProbe(time) { Gate = gate };
-
- var seedOptions = Fast() with { MaxConcurrency = 3, MaxHosts = 100 };
- var frontier = new CrawlFrontier(seedOptions);
- for (var i = 0; i < 20; i++)
- {
- frontier.AddSeed(Host($"h{i}.example.org"), time.GetUtcNow());
- }
-
- var run = new MsspCrawler(seedOptions, probe, frontier, time).RunAsync();
-
- // Let the workers pile up against the gate, then release them.
- await Task.Delay(150);
- var peakWhileBlocked = probe.PeakConcurrency;
- gate.SetResult();
- await run;
-
- await Assert.That(peakWhileBlocked).IsEqualTo(3);
- await Assert.That(probe.PeakConcurrency).IsLessThanOrEqualTo(3);
- await Assert.That(probe.Visited.Count).IsEqualTo(20);
- }
-
- [Test]
- public async Task ARunResumesWhereThePreviousOneStopped()
- {
- var time = new VirtualTimeProvider();
- var options = Fast() with { MaxHosts = 2, MaxDepth = 99 };
-
- var probe = new FakeProbe(time);
- for (var i = 0; i < 20; i++)
- {
- probe.Referring(Host($"h{i}.example.org"), Host($"h{i + 1}.example.org"));
- }
-
- // First run: stopped by the host cap after two.
- var first = new CrawlFrontier(options);
- first.AddSeed(Host("h0.example.org"), time.GetUtcNow());
- IReadOnlyCollection saved = [];
- var firstSummary = await new MsspCrawler(options, probe, first, time, persist: records => saved = records)
- .RunAsync();
-
- await Assert.That(firstSummary.StopReason).IsEqualTo(CrawlStopReason.HostCap);
- await Assert.That(probe.Visited).IsEquivalentTo(new[] { Host("h0.example.org"), Host("h1.example.org") });
-
- // Second run, from the saved state and with no seeds at all: it must continue rather than start
- // over, and must not re-dial the two it has just visited.
- var second = new CrawlFrontier(options, saved);
- var resumed = new FakeProbe(time);
- for (var i = 0; i < 20; i++)
- {
- resumed.Referring(Host($"h{i}.example.org"), Host($"h{i + 1}.example.org"));
- }
-
- await new MsspCrawler(options, resumed, second, time).RunAsync();
-
- await Assert.That(resumed.Visited).IsEquivalentTo(new[] { Host("h2.example.org"), Host("h3.example.org") });
- }
-
- [Test]
- public async Task ARunWithNothingDueContactsNobody()
- {
- // The whole point of the revisit interval: a second run started straight after the first must be
- // a no-op, not a second pass over the same servers.
- var time = new VirtualTimeProvider();
- var options = Fast() with { MaxHosts = 100 };
-
- var frontier = new CrawlFrontier(options);
- frontier.AddSeed(Host("a.example.org"), time.GetUtcNow());
- var probe = new FakeProbe(time).Referring(Host("a.example.org"));
-
- IReadOnlyCollection saved = [];
- await new MsspCrawler(options, probe, frontier, time, persist: records => saved = records).RunAsync();
- await Assert.That(probe.Visited.Count).IsEqualTo(1);
-
- var again = new FakeProbe(time);
- var summary = await new MsspCrawler(options, again, new CrawlFrontier(options, saved), time).RunAsync();
-
- await Assert.That(again.Visited).IsEmpty();
- await Assert.That(summary.Contacted).IsEqualTo(0);
-
- // …and once the interval has passed, it is due again.
- time.Advance(TimeSpan.FromHours(25));
- var later = new FakeProbe(time);
- await new MsspCrawler(options, later, new CrawlFrontier(options, saved), time).RunAsync();
- await Assert.That(later.Visited.Count).IsEqualTo(1);
- }
-
- [Test]
- public async Task ADryRunContactsNobodyAndLeavesTheScheduleAlone()
- {
- var time = new VirtualTimeProvider();
- var options = Fast() with { DryRun = true };
- var frontier = new CrawlFrontier(options);
- frontier.AddSeed(Host("a.example.org"), time.GetUtcNow());
-
- var probe = new FakeProbe(time);
- var summary = await new MsspCrawler(options, probe, frontier, time).RunAsync();
-
- await Assert.That(probe.Visited).IsEmpty();
- await Assert.That(summary.Contacted).IsEqualTo(0);
- await Assert.That(frontier.Records.Single().IsDue(time.GetUtcNow())).IsTrue();
- }
-
- [Test]
- public async Task AFailingHostBacksOffAndIsNotRetriedWithinTheSameRun()
- {
- var time = new VirtualTimeProvider();
- var options = Fast() with { MaxHosts = 100 };
- var host = Host("dead.example.org");
-
- var frontier = new CrawlFrontier(options);
- frontier.AddSeed(host, time.GetUtcNow());
- var probe = new FakeProbe(time).Answering(host, CrawlOutcome.ConnectFailed, "ConnectionRefused");
-
- await new MsspCrawler(options, probe, frontier, time).RunAsync();
-
- await Assert.That(probe.Visited.Count).IsEqualTo(1);
- var record = frontier.Records.Single();
- await Assert.That(record.ConsecutiveFailures).IsEqualTo(1);
- await Assert.That(record.LastError).IsEqualTo("ConnectionRefused");
- await Assert.That(record.NotBefore).IsNotNull();
- }
-
- [Test]
- public async Task AProbeThatThrowsIsRecordedRatherThanTakingTheRunDown()
- {
- var time = new VirtualTimeProvider();
- var options = Fast();
- var frontier = new CrawlFrontier(options);
- frontier.AddSeed(Host("a.example.org"), time.GetUtcNow());
-
- var summary = await new MsspCrawler(options, new ThrowingProbe(), frontier, time).RunAsync();
-
- await Assert.That(summary.Results.Single().Outcome).IsEqualTo(CrawlOutcome.Error);
- await Assert.That(summary.StopReason).IsEqualTo(CrawlStopReason.Exhausted);
- }
-
- private sealed class ThrowingProbe : SharpMUTerm.Crawler.Probing.IMsspProbe
- {
- public Task ProbeAsync(MsspHost host, CancellationToken cancellationToken) =>
- throw new InvalidOperationException("a bug in the probe");
- }
-}
diff --git a/tests/SharpMUTerm.Crawler.Tests/PersistenceTests.cs b/tests/SharpMUTerm.Crawler.Tests/PersistenceTests.cs
deleted file mode 100644
index 20f0579..0000000
--- a/tests/SharpMUTerm.Crawler.Tests/PersistenceTests.cs
+++ /dev/null
@@ -1,431 +0,0 @@
-using System.Text.Json;
-using SharpMUTerm.Core.Telnet.Mssp;
-using SharpMUTerm.Crawler.Model;
-using SharpMUTerm.Crawler.Output;
-using SharpMUTerm.Crawler.Scheduling;
-using SharpMUTerm.Crawler.Storage;
-using SharpMUTerm.Crawler.Tests.Support;
-
-namespace SharpMUTerm.Crawler.Tests;
-
-///
-/// The state file and the two outputs. Everything here writes into a temporary directory of its own
-/// and deletes it: this tool must never write anywhere near the user's configuration.
-///
-public class PersistenceTests
-{
- private static readonly DateTimeOffset Now = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
-
- private static MsspHost Host(string name, int port = 4201) => MsspHost.Create(name, port)!;
-
- private static string Scratch()
- {
- var path = Path.Combine(Path.GetTempPath(), "sharpmuterm-crawler-tests", Guid.NewGuid().ToString("N"));
- Directory.CreateDirectory(path);
- return path;
- }
-
- [Test]
- public async Task StateSurvivesARoundTripSoASecondRunResumes()
- {
- var directory = Scratch();
- try
- {
- var store = new CrawlStore(Path.Combine(directory, "state.json"));
- var records = new[]
- {
- new HostRecord
- {
- Host = Host("a.example.org"),
- Depth = 0,
- FirstSeen = Now,
- LastAttempt = Now,
- LastSuccess = Now,
- LastOutcome = CrawlOutcome.MsspReceived,
- Name = "Corvid Nest",
- CrawlDelayHours = 5,
- Attempts = 3,
- NotBefore = Now + TimeSpan.FromHours(24),
- },
- new HostRecord
- {
- Host = Host("b.example.org", 4000),
- Depth = 2,
- DiscoveredFrom = Host("a.example.org"),
- FirstSeen = Now,
- LastOutcome = CrawlOutcome.ConnectFailed,
- LastError = "ConnectionRefused",
- ConsecutiveFailures = 2,
- NotBefore = Now + TimeSpan.FromHours(6),
- },
- new HostRecord
- {
- Host = Host("dead.example.org"),
- FirstSeen = Now,
- Retired = true,
- ConsecutiveFailures = 5,
- },
- };
-
- store.Save(records);
- var loaded = store.Load(out var problem);
-
- await Assert.That(problem).IsNull();
- await Assert.That(loaded.Count).IsEqualTo(3);
-
- var a = loaded.Single(r => r.Host == Host("a.example.org"));
- await Assert.That(a.Name).IsEqualTo("Corvid Nest");
- await Assert.That(a.CrawlDelayHours).IsEqualTo(5);
- await Assert.That(a.NotBefore).IsEqualTo(Now + TimeSpan.FromHours(24));
- await Assert.That(a.IsDue(Now)).IsFalse();
- await Assert.That(a.IsDue(Now + TimeSpan.FromHours(25))).IsTrue();
-
- var b = loaded.Single(r => r.Host == Host("b.example.org", 4000));
- await Assert.That(b.DiscoveredFrom).IsEqualTo(Host("a.example.org"));
- await Assert.That(b.ConsecutiveFailures).IsEqualTo(2);
-
- var retired = loaded.Single(r => r.Host == Host("dead.example.org"));
- await Assert.That(retired.Retired).IsTrue();
- await Assert.That(retired.IsDue(Now + TimeSpan.FromDays(365))).IsFalse();
- }
- finally
- {
- Directory.Delete(directory, recursive: true);
- }
- }
-
- [Test]
- public async Task AHostSpelledDifferentlyInTheFileIsNormalisedOnTheWayBackIn()
- {
- // A state file written by an older build, or edited by hand. Two spellings of one host in the
- // frontier is exactly the duplicate the normalisation exists to prevent.
- var directory = Scratch();
- try
- {
- var path = Path.Combine(directory, "state.json");
- File.WriteAllText(path, """
- {
- "version": 1,
- "savedAt": "2026-01-01T00:00:00+00:00",
- "hosts": [
- { "host": "A.Example.ORG.", "port": 4201, "firstSeen": "2026-01-01T00:00:00+00:00" },
- { "host": "2001:0DB8:0000::0001", "port": 4201, "firstSeen": "2026-01-01T00:00:00+00:00" },
- { "host": "", "port": 4201, "firstSeen": "2026-01-01T00:00:00+00:00" }
- ]
- }
- """);
-
- var loaded = new CrawlStore(path).Load(out var problem);
-
- await Assert.That(problem).IsNull();
-
- // The unusable entry is dropped rather than carried as a host that can never be dialled.
- await Assert.That(loaded.Count).IsEqualTo(2);
- await Assert.That(loaded.Select(r => r.Host.Host))
- .IsEquivalentTo(new[] { "a.example.org", "2001:db8::1" });
- }
- finally
- {
- Directory.Delete(directory, recursive: true);
- }
- }
-
- [Test]
- public async Task AnUnreadableStateFileIsReportedAndTreatedAsAbsent()
- {
- var directory = Scratch();
- try
- {
- var path = Path.Combine(directory, "state.json");
- File.WriteAllText(path, "{ this is not json");
-
- var loaded = new CrawlStore(path).Load(out var problem);
-
- await Assert.That(loaded).IsEmpty();
- await Assert.That(problem).IsNotNull();
- }
- finally
- {
- Directory.Delete(directory, recursive: true);
- }
- }
-
- [Test]
- public async Task AStateFileFromAFutureVersionIsRefusedRatherThanMisread()
- {
- var directory = Scratch();
- try
- {
- var path = Path.Combine(directory, "state.json");
- File.WriteAllText(path, """{ "version": 99, "hosts": [] }""");
-
- new CrawlStore(path).Load(out var problem);
- await Assert.That(problem).Contains("newer than this build");
- }
- finally
- {
- Directory.Delete(directory, recursive: true);
- }
- }
-
- [Test]
- public async Task TheObservationLogRecordsWhenEachRecordWasSeenAndKeepsEveryArray()
- {
- var directory = Scratch();
- try
- {
- var path = Path.Combine(directory, "observations.jsonl");
- var data = MsspWire.Report(MsspWire.RepresentativeReport("peer.example.net 4000"));
-
- using (var log = new ObservationLog(path))
- {
- log.Append(new ProbeResult
- {
- Host = Host("a.example.org"),
- Outcome = CrawlOutcome.MsspReceived,
- ObservedAt = Now,
- Duration = TimeSpan.FromMilliseconds(412),
- Data = data,
- });
-
- log.Append(new ProbeResult
- {
- Host = Host("b.example.org"),
- Outcome = CrawlOutcome.ConnectFailed,
- ObservedAt = Now + TimeSpan.FromSeconds(2),
- Error = "ConnectionRefused",
- });
- }
-
- var lines = File.ReadAllLines(path);
- await Assert.That(lines.Length).IsEqualTo(2);
-
- using var first = JsonDocument.Parse(lines[0]);
- var root = first.RootElement;
-
- // MSSP goes stale; a record without a timestamp is data of unknown age.
- await Assert.That(root.GetProperty("observedAt").GetDateTimeOffset()).IsEqualTo(Now);
- await Assert.That(root.GetProperty("host").GetString()).IsEqualTo("a.example.org");
- await Assert.That(root.GetProperty("players").GetInt32()).IsEqualTo(17);
-
- // Arrays survive to the file, which is the whole reason the model holds lists.
- var ports = root.GetProperty("variables").GetProperty("PORT").EnumerateArray()
- .Select(v => v.GetString() ?? string.Empty).ToArray();
- await Assert.That(ports).IsEquivalentTo(new[] { "80", "23", "4201" });
-
- // Including the variables no model knows about.
- await Assert.That(root.GetProperty("variables").GetProperty("CORVID SPECIFIC")[0].GetString())
- .IsEqualTo("nevermore");
-
- using var second = JsonDocument.Parse(lines[1]);
- await Assert.That(second.RootElement.GetProperty("outcome").GetString()).IsEqualTo("ConnectFailed");
- await Assert.That(second.RootElement.GetProperty("error").GetString()).IsEqualTo("ConnectionRefused");
- }
- finally
- {
- Directory.Delete(directory, recursive: true);
- }
- }
-
- [Test]
- public async Task NeitherOutputStartsWithAByteOrderMark()
- {
- // Found by actually reading the files a run produced: Encoding.UTF8 emits a BOM, and a BOM at the
- // head of a JSON-lines file breaks the first record for every consumer that does not know to
- // strip it — Python's own json module among them. The Markdown grows a stray glyph before its
- // first heading. Both are only visible if somebody opens the file, so they get a test.
- var directory = Scratch();
- try
- {
- var observations = Path.Combine(directory, "observations.jsonl");
- using (var log = new ObservationLog(observations))
- {
- log.Append(new ProbeResult
- {
- Host = Host("a.example.org"),
- Outcome = CrawlOutcome.NoMssp,
- ObservedAt = Now,
- });
- }
-
- var report = Path.Combine(directory, "report.md");
- CrawlReport.Write(report, new CrawlSummary
- {
- StartedAt = Now,
- FinishedAt = Now,
- StopReason = CrawlStopReason.Exhausted,
- Results = [],
- Hosts = [],
- Verdicts = new Dictionary(),
- }, new CrawlOptions());
-
- byte[] bom = [0xEF, 0xBB, 0xBF];
- await Assert.That(File.ReadAllBytes(observations).Take(3)).IsNotEquivalentTo(bom);
- await Assert.That(File.ReadAllBytes(report).Take(3)).IsNotEquivalentTo(bom);
-
- // And the record really does parse as JSON when read straight off the first byte.
- using var parsed = JsonDocument.Parse(File.ReadAllText(observations));
- await Assert.That(parsed.RootElement.GetProperty("host").GetString()).IsEqualTo("a.example.org");
- }
- finally
- {
- Directory.Delete(directory, recursive: true);
- }
- }
-
- [Test]
- public async Task TheObservationLogIsAppendedToRatherThanReplaced()
- {
- var directory = Scratch();
- try
- {
- var path = Path.Combine(directory, "observations.jsonl");
- var result = new ProbeResult
- {
- Host = Host("a.example.org"),
- Outcome = CrawlOutcome.NoMssp,
- ObservedAt = Now,
- };
-
- using (var first = new ObservationLog(path))
- {
- first.Append(result);
- }
-
- using (var second = new ObservationLog(path))
- {
- second.Append(result);
- }
-
- await Assert.That(File.ReadAllLines(path).Length).IsEqualTo(2);
- }
- finally
- {
- Directory.Delete(directory, recursive: true);
- }
- }
-
- [Test]
- public async Task TheReportSaysWhatHappenedAndWhy()
- {
- var data = MsspWire.Report(MsspWire.RepresentativeReport("peer.example.net 4000"));
-
- var summary = new CrawlSummary
- {
- StartedAt = Now,
- FinishedAt = Now + TimeSpan.FromMinutes(3),
- StopReason = CrawlStopReason.HostCap,
- Results =
- [
- new ProbeResult
- {
- Host = Host("a.example.org"),
- Outcome = CrawlOutcome.MsspReceived,
- ObservedAt = Now,
- Data = data,
- },
- new ProbeResult
- {
- Host = Host("b.example.org"),
- Outcome = CrawlOutcome.ConnectFailed,
- ObservedAt = Now,
- Error = "ConnectionRefused",
- },
- ],
- Hosts =
- [
- new HostRecord { Host = Host("a.example.org"), FirstSeen = Now, NotBefore = Now + TimeSpan.FromHours(24) },
- new HostRecord
- {
- Host = Host("peer.example.net", 4000),
- Depth = 1,
- DiscoveredFrom = Host("a.example.org"),
- FirstSeen = Now,
- },
- ],
- Verdicts = new Dictionary
- {
- [DiscoveryVerdict.Added] = 1,
- [DiscoveryVerdict.AlreadyKnown] = 4,
- },
- };
-
- var report = CrawlReport.Render(summary, new CrawlOptions());
-
- await Assert.That(report).Contains("the host cap was reached");
- await Assert.That(report).Contains("Corvid Nest");
- await Assert.That(report).Contains("peer.example.net:4000");
- await Assert.That(report).Contains("ConnectionRefused");
- await Assert.That(report).Contains("a cycle, or two servers naming the same peer");
- await Assert.That(report).Contains("2026-01-01 12:00:00Z");
- }
-
- [Test]
- public async Task AServerNameCannotBreakTheReportsTable()
- {
- // Every string in that table came off the wire from a stranger. A MUD called "Pipe|Dream" must
- // not be able to add a column to somebody's report.
- var data = MsspWire.Report(("NAME", ["Pipe|Dream\nSecond line"]));
-
- var summary = new CrawlSummary
- {
- StartedAt = Now,
- FinishedAt = Now,
- StopReason = CrawlStopReason.Exhausted,
- Results = [new ProbeResult { Host = Host("a.example.org"), Outcome = CrawlOutcome.MsspReceived, ObservedAt = Now, Data = data }],
- Hosts = [],
- Verdicts = new Dictionary(),
- };
-
- var row = CrawlReport.Render(summary, new CrawlOptions())
- .Split('\n')
- .Single(line => line.Contains("Pipe"));
-
- await Assert.That(row).Contains("Pipe\\|Dream");
- await Assert.That(row).DoesNotContain("\r");
-
- // Seven columns, so eight column separators — the one inside the name is escaped and does not
- // count, which is the whole point.
- var separators = row.Where((c, i) => c == '|' && (i == 0 || row[i - 1] != '\\')).Count();
- await Assert.That(separators).IsEqualTo(8);
- }
-
- ///
- /// A report is the same bytes wherever it was produced. StringBuilder.AppendLine emits
- /// , so the renderer used to write CRLF on Windows and LF on Linux —
- /// the same crawl, two different files. A report gets committed, diffed and pasted, and one whose
- /// shape follows the machine that made it is useless as a baseline.
- ///
- /// This assertion only bites on Windows, where Environment.NewLine is CRLF; on Linux it
- /// is LF and the test passes whether or not the normalisation is there. It is the Windows CI job that
- /// guards this, which is why the Crawler suite now runs there.
- ///
- ///
- [Test]
- public async Task AReportIsTheSameBytesOnEveryPlatform()
- {
- var summary = new CrawlSummary
- {
- StartedAt = Now,
- FinishedAt = Now,
- StopReason = CrawlStopReason.Exhausted,
- Results =
- [
- new ProbeResult
- {
- Host = Host("a.example.org"),
- Outcome = CrawlOutcome.MsspReceived,
- ObservedAt = Now,
- Data = MsspWire.Report(("NAME", ["Corvid Nest"])),
- },
- ],
- Hosts = [],
- Verdicts = new Dictionary(),
- };
-
- var report = CrawlReport.Render(summary, new CrawlOptions());
-
- await Assert.That(report).DoesNotContain("\r");
- await Assert.That(report).Contains("\n").Because("the report is still line-delimited, just with LF");
- }
-}
diff --git a/tests/SharpMUTerm.Crawler.Tests/ProbeTests.cs b/tests/SharpMUTerm.Crawler.Tests/ProbeTests.cs
deleted file mode 100644
index 476c4d9..0000000
--- a/tests/SharpMUTerm.Crawler.Tests/ProbeTests.cs
+++ /dev/null
@@ -1,249 +0,0 @@
-using System.Text;
-using SharpMUTerm.Core.Telnet.Mssp;
-using SharpMUTerm.Core.Transport;
-using SharpMUTerm.Crawler.Model;
-using SharpMUTerm.Crawler.Probing;
-using SharpMUTerm.Crawler.Tests.Support;
-
-namespace SharpMUTerm.Crawler.Tests;
-
-///
-/// The probe end to end: a real TelnetSession over a scripted server, negotiating for real.
-///
-public class ProbeTests
-{
- private static MsspHost Host(string name = "server.example.org", int port = 4201) =>
- MsspHost.Create(name, port)!;
-
- private static CrawlOptions Options => new()
- {
- ConnectTimeout = TimeSpan.FromSeconds(5),
- MsspTimeout = TimeSpan.FromSeconds(5),
- };
-
- private static ScriptedTransport MsspServer(params string[] referrals) =>
- new ScriptedTransport { Greeting = MsspWire.Offer() }
- .RespondingToDo(MsspWire.Mssp, MsspWire.Subnegotiation(MsspWire.RepresentativeReport(referrals)));
-
- [Test]
- public async Task AServerThatOffersMsspIsReadCompletely()
- {
- var transport = MsspServer("peer.example.net 4000", "2001:db8::5 4201");
- var probe = new TelnetMsspProbe(Options, _ => transport);
-
- var result = await probe.ProbeAsync(Host(), CancellationToken.None);
-
- await Assert.That(result.Outcome).IsEqualTo(CrawlOutcome.MsspReceived);
- await Assert.That(result.Data).IsNotNull();
-
- var data = result.Data!;
- await Assert.That(data.Name).IsEqualTo("Corvid Nest");
- await Assert.That(data.Players).IsEqualTo(17);
-
- // The array-valued variables: the case a crawler exists for.
- await Assert.That(data.Ports).IsEquivalentTo(new[] { 80, 23, 4201 });
- await Assert.That(data.Referrals.Select(r => r.ToReferralString()))
- .IsEquivalentTo(new[] { "peer.example.net 4000", "2001:db8::5 4201" });
-
- // The booleans and the unknown variables, likewise.
- await Assert.That(data.Flag("ANSI")).IsTrue();
- await Assert.That(data.Default("CORVID SPECIFIC")).IsEqualTo("nevermore");
- }
-
- [Test]
- public async Task AServerThatNegotiatesNoMsspAtAllIsRecordedAsSuchRatherThanAsAFailure()
- {
- // Most MU* servers. It offers ECHO and SUPPRESS-GO-AHEAD, prints a login banner, and never
- // mentions MSSP.
- var banner = new List();
- banner.AddRange([MsspWire.Iac, MsspWire.Will, 1]); // WILL ECHO
- banner.AddRange([MsspWire.Iac, MsspWire.Will, 3]); // WILL SUPPRESS-GO-AHEAD
- banner.AddRange(Encoding.ASCII.GetBytes("\r\nWelcome to Some MUD.\r\nBy what name do you wish to be known? "));
-
- var transport = new ScriptedTransport { Greeting = [.. banner] };
- var options = Options with { MsspTimeout = TimeSpan.FromMilliseconds(300) };
-
- var result = await new TelnetMsspProbe(options, _ => transport).ProbeAsync(Host(), CancellationToken.None);
-
- await Assert.That(result.Outcome).IsEqualTo(CrawlOutcome.NoMssp);
- await Assert.That(result.Data).IsNull();
- await Assert.That(result.IsFailure).IsFalse();
- }
-
- [Test]
- public async Task AServerThatOnlyAnswersWhenAskedIsStillRead()
- {
- // The case that made a live server look as if it had no MSSP at all. The specification says a
- // server "should" send IAC WILL MSSP on connect; plenty do not, and answer IAC DO MSSP instead.
- // This server volunteers nothing — no greeting whatsoever — so it is only reachable by asking.
- var transport = new ScriptedTransport()
- .RespondingToDo(MsspWire.Mssp, MsspWire.Subnegotiation(("NAME", ["Silent Until Asked"])));
-
- var result = await new TelnetMsspProbe(Options, _ => transport).ProbeAsync(Host(), CancellationToken.None);
-
- await Assert.That(result.Outcome).IsEqualTo(CrawlOutcome.MsspReceived);
- await Assert.That(result.Data!.Name).IsEqualTo("Silent Until Asked");
- }
-
- [Test]
- public async Task TheProbeAsksForMsspRatherThanWaitingToBeOffered()
- {
- var transport = new ScriptedTransport();
- var probing = new TelnetMsspProbe(
- Options with { MsspTimeout = TimeSpan.FromMilliseconds(400) }, _ => transport)
- .ProbeAsync(Host(), CancellationToken.None);
-
- await Assert.That(await transport.WaitForSentAsync([MsspWire.Iac, MsspWire.Do, MsspWire.Mssp]))
- .IsTrue()
- .Because("a crawler that only listens misses every server that waits to be asked");
-
- transport.Close();
- await probing;
- }
-
- [Test]
- public async Task AServerThatHangsUpWithoutMsspIsRecordedAsHavingNone()
- {
- var transport = new ScriptedTransport();
- var probe = new TelnetMsspProbe(Options, _ => transport);
-
- var probing = probe.ProbeAsync(Host(), CancellationToken.None);
- transport.Close();
-
- await Assert.That((await probing).Outcome).IsEqualTo(CrawlOutcome.NoMssp);
- }
-
- [Test]
- public async Task NothingButTelnetNegotiationIsEverSentToTheServer()
- {
- // The politeness requirement, asserted against the bytes rather than against a reading of the
- // code: a crawler must never log in and never send a command. Every byte the probe puts on the
- // wire has to belong to an IAC sequence.
- var transport = MsspServer("peer.example.net 4000");
- var result = await new TelnetMsspProbe(Options, _ => transport).ProbeAsync(Host(), CancellationToken.None);
-
- await Assert.That(result.Outcome).IsEqualTo(CrawlOutcome.MsspReceived);
-
- var stray = StrayDataBytes(transport.Sent);
- await Assert.That(stray)
- .IsEmpty()
- .Because($"the crawler sent application data: \"{Encoding.ASCII.GetString([.. stray])}\"");
- }
-
- [Test]
- public async Task NothingIsSentEvenToAServerThatPromptsForALogin()
- {
- // The case that would tempt a client: a login banner arrives and the connection sits there. It
- // must still be answered with silence.
- var banner = Encoding.ASCII.GetBytes("\r\nEnter your name: ");
- var transport = new ScriptedTransport { Greeting = banner };
- var options = Options with { MsspTimeout = TimeSpan.FromMilliseconds(300) };
-
- await new TelnetMsspProbe(options, _ => transport).ProbeAsync(Host(), CancellationToken.None);
-
- await Assert.That(StrayDataBytes(transport.Sent)).IsEmpty();
- }
-
- [Test]
- public async Task TheCrawlerNamesItselfWhenTheServerAsksWhatConnected()
- {
- // MTTS: the server sends IAC SB TTYPE SEND IAC SE and the client answers IAC SB TTYPE IS .
- // A crawler that did not answer with its own name would be logged as whatever the telnet layer
- // calls itself, which tells a server operator nothing.
- const byte ttype = 24;
- const byte send = 1;
-
- var transport = new ScriptedTransport { Greeting = [MsspWire.Iac, MsspWire.Do, ttype] };
-
- var probing = new TelnetMsspProbe(
- Options with { MsspTimeout = TimeSpan.FromSeconds(3) },
- _ => transport).ProbeAsync(Host(), CancellationToken.None);
-
- // Wait for the client to agree to TTYPE, then ask it for one.
- await Assert.That(await transport.WaitForSentAsync([MsspWire.Iac, MsspWire.Will, ttype])).IsTrue();
- transport.SendToClient(MsspWire.Iac, MsspWire.Sb, ttype, send, MsspWire.Iac, MsspWire.Se);
-
- await Assert.That(await transport.WaitForSentAsync(Encoding.ASCII.GetBytes("SHARPMUTERM-MSSPCRAWLER")))
- .IsTrue();
-
- transport.Close();
- await probing;
-
- var text = Encoding.ASCII.GetString(transport.Sent);
- await Assert.That(text).Contains("SHARPMUTERM-MSSPCRAWLER");
- await Assert.That(text).DoesNotContain("TNC");
- }
-
- [Test]
- public async Task AConnectionThatIsRefusedIsRecordedAsAFailure()
- {
- var result = await new TelnetMsspProbe(Options, _ => new RefusingTransport())
- .ProbeAsync(Host(), CancellationToken.None);
-
- await Assert.That(result.Outcome).IsEqualTo(CrawlOutcome.ConnectFailed);
- await Assert.That(result.IsFailure).IsTrue();
- await Assert.That(result.Error).IsEqualTo("ConnectionRefused");
- }
-
- ///
- /// Every byte written that is not part of an IAC sequence — i.e. everything that would be
- /// application data reaching the game.
- ///
- private static List StrayDataBytes(byte[] sent)
- {
- var stray = new List();
- var i = 0;
- while (i < sent.Length)
- {
- if (sent[i] != MsspWire.Iac)
- {
- stray.Add(sent[i]);
- i++;
- continue;
- }
-
- if (i + 1 >= sent.Length)
- {
- break;
- }
-
- var command = sent[i + 1];
- if (command == MsspWire.Sb)
- {
- // Skip to IAC SE, honouring IAC IAC inside the payload.
- i += 2;
- while (i + 1 < sent.Length && !(sent[i] == MsspWire.Iac && sent[i + 1] == MsspWire.Se))
- {
- i += sent[i] == MsspWire.Iac && sent[i + 1] == MsspWire.Iac ? 2 : 1;
- }
-
- i += 2;
- continue;
- }
-
- i += command is >= MsspWire.Will and <= MsspWire.Dont ? 3 : 2;
- }
-
- return stray;
- }
-
- private sealed class RefusingTransport : ITransport
- {
- public bool IsConnected => false;
-
- public string? RemoteDescription => null;
-
- public Task ConnectAsync(CancellationToken cancellationToken = default) =>
- throw new System.Net.Sockets.SocketException((int)System.Net.Sockets.SocketError.ConnectionRefused);
-
- public ValueTask SendAsync(ReadOnlyMemory data, CancellationToken cancellationToken = default) =>
- ValueTask.CompletedTask;
-
- public ValueTask ReceiveAsync(Memory buffer, CancellationToken cancellationToken = default) =>
- ValueTask.FromResult(0);
-
- public Task CloseAsync() => Task.CompletedTask;
-
- public ValueTask DisposeAsync() => ValueTask.CompletedTask;
- }
-}
diff --git a/tests/SharpMUTerm.Crawler.Tests/RateLimitTests.cs b/tests/SharpMUTerm.Crawler.Tests/RateLimitTests.cs
deleted file mode 100644
index 26cb00e..0000000
--- a/tests/SharpMUTerm.Crawler.Tests/RateLimitTests.cs
+++ /dev/null
@@ -1,148 +0,0 @@
-using SharpMUTerm.Core.Telnet.Mssp;
-using SharpMUTerm.Crawler.Scheduling;
-using SharpMUTerm.Crawler.Tests.Support;
-
-namespace SharpMUTerm.Crawler.Tests;
-
-///
-/// The rate limiter, driven by a clock the test moves by hand. Nothing here sleeps: a limiter tested
-/// by waiting for its own interval proves only that the machine was not too busy.
-///
-public class RateLimitTests
-{
- private static MsspHost Host(string name, int port = 4201) => MsspHost.Create(name, port)!;
-
- private static readonly CrawlOptions Options = new()
- {
- GlobalInterval = TimeSpan.FromSeconds(2),
- PerHostInterval = TimeSpan.FromMinutes(5),
- };
-
- [Test]
- public async Task TheFirstConnectionIsAllowedImmediately()
- {
- var limiter = new CrawlRateLimiter(Options, new ManualTimeProvider());
-
- await Assert.That(limiter.DelayBefore(Host("a.example.org"))).IsEqualTo(TimeSpan.Zero);
- }
-
- [Test]
- public async Task TheGlobalIntervalHoldsBackTheNextConnectionToADifferentHost()
- {
- var time = new ManualTimeProvider();
- var limiter = new CrawlRateLimiter(Options, time);
-
- limiter.RecordStart(Host("a.example.org"));
-
- // A different host, so the per-host limit has nothing to say; the global one does.
- await Assert.That(limiter.DelayBefore(Host("b.example.org"))).IsEqualTo(TimeSpan.FromSeconds(2));
-
- time.Advance(TimeSpan.FromSeconds(1));
- await Assert.That(limiter.DelayBefore(Host("b.example.org"))).IsEqualTo(TimeSpan.FromSeconds(1));
-
- time.Advance(TimeSpan.FromSeconds(1));
- await Assert.That(limiter.DelayBefore(Host("b.example.org"))).IsEqualTo(TimeSpan.Zero);
- }
-
- [Test]
- public async Task TheSameHostWaitsTheLongerPerHostInterval()
- {
- var time = new ManualTimeProvider();
- var limiter = new CrawlRateLimiter(Options, time);
-
- var host = Host("a.example.org");
- limiter.RecordStart(host);
-
- time.Advance(TimeSpan.FromSeconds(10));
-
- // The global interval is long since satisfied; the per-host one is not, and the longer of the
- // two is what is owed.
- await Assert.That(limiter.DelayBefore(Host("b.example.org"))).IsEqualTo(TimeSpan.Zero);
- await Assert.That(limiter.DelayBefore(host)).IsEqualTo(TimeSpan.FromSeconds(290));
-
- time.Advance(TimeSpan.FromSeconds(290));
- await Assert.That(limiter.DelayBefore(host)).IsEqualTo(TimeSpan.Zero);
- }
-
- [Test]
- public async Task PortsOnOneMachineAreSeparateHostsForRateLimitingButTheGlobalLimitStillApplies()
- {
- // Two ports on one server are two entries; the per-host limit is keyed on both. The global limit
- // is what stops a server with six advertised ports being dialled six times in a second.
- var time = new ManualTimeProvider();
- var limiter = new CrawlRateLimiter(Options, time);
-
- limiter.RecordStart(Host("a.example.org", 4201));
- await Assert.That(limiter.DelayBefore(Host("a.example.org", 4202))).IsEqualTo(TimeSpan.FromSeconds(2));
- }
-
- [Test]
- public async Task AStreamOfConnectionsIsSpacedByTheGlobalInterval()
- {
- // The property that matters: over a run, connections start no closer together than the interval.
- var time = new ManualTimeProvider();
- var limiter = new CrawlRateLimiter(Options, time);
- var starts = new List();
-
- for (var i = 0; i < 5; i++)
- {
- var host = Host($"host{i}.example.org");
- var wait = limiter.DelayBefore(host);
- time.Advance(wait);
- starts.Add(time.GetUtcNow());
- limiter.RecordStart(host);
- }
-
- var gaps = starts.Zip(starts.Skip(1), (first, second) => second - first).ToList();
- await Assert.That(gaps).IsNotEmpty();
- foreach (var gap in gaps)
- {
- await Assert.That(gap).IsGreaterThanOrEqualTo(TimeSpan.FromSeconds(2));
- }
- }
-
- [Test]
- public async Task WaitingForATurnStampsTheStartSoTheNextCallerIsHeldBack()
- {
- var time = new ManualTimeProvider();
- var limiter = new CrawlRateLimiter(Options, time);
-
- // Nothing to wait for, so this completes without the clock moving at all.
- await limiter.WaitForTurnAsync(Host("a.example.org"), CancellationToken.None);
-
- await Assert.That(limiter.DelayBefore(Host("b.example.org"))).IsEqualTo(TimeSpan.FromSeconds(2));
- }
-
- [Test]
- public async Task AZeroIntervalMeansNoWaitingAtAll()
- {
- // The configuration a test harness uses, and the one a careless operator might. It must work
- // rather than divide by something.
- var limiter = new CrawlRateLimiter(
- new CrawlOptions { GlobalInterval = TimeSpan.Zero, PerHostInterval = TimeSpan.Zero },
- new ManualTimeProvider());
-
- var host = Host("a.example.org");
- limiter.RecordStart(host);
-
- await Assert.That(limiter.DelayBefore(host)).IsEqualTo(TimeSpan.Zero);
- }
-
- [Test]
- public async Task TheDefaultsAreConservative()
- {
- // A pin on the politeness settings themselves. These are the numbers that reach other people's
- // servers, and lowering one should be a deliberate act with a test to change.
- var defaults = new CrawlOptions();
-
- await Assert.That(defaults.MaxConcurrency).IsEqualTo(4);
- await Assert.That(defaults.GlobalInterval).IsEqualTo(TimeSpan.FromSeconds(1));
- await Assert.That(defaults.PerHostInterval).IsEqualTo(TimeSpan.FromMinutes(5));
- await Assert.That(defaults.RevisitInterval).IsEqualTo(TimeSpan.FromHours(24));
- await Assert.That(defaults.NoMsspRevisitInterval).IsEqualTo(TimeSpan.FromDays(7));
- await Assert.That(defaults.MaxHosts).IsEqualTo(500);
- await Assert.That(defaults.MaxDuration).IsEqualTo(TimeSpan.FromHours(1));
- await Assert.That(defaults.MaxDepth).IsEqualTo(4);
- await Assert.That(defaults.FollowPrivateAddresses).IsFalse();
- }
-}
diff --git a/tests/SharpMUTerm.Crawler.Tests/ReferralTests.cs b/tests/SharpMUTerm.Crawler.Tests/ReferralTests.cs
deleted file mode 100644
index 7f45376..0000000
--- a/tests/SharpMUTerm.Crawler.Tests/ReferralTests.cs
+++ /dev/null
@@ -1,254 +0,0 @@
-using SharpMUTerm.Core.Telnet.Mssp;
-using SharpMUTerm.Crawler;
-using SharpMUTerm.Crawler.Model;
-using SharpMUTerm.Crawler.Scheduling;
-using SharpMUTerm.Crawler.Tests.Support;
-
-namespace SharpMUTerm.Crawler.Tests;
-
-///
-/// REFERRAL: the format the specification defines, and what a crawler does with what it finds.
-///
-public class ReferralTests
-{
- private static readonly DateTimeOffset Now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
-
- private static MsspHost Host(string value)
- {
- MsspHost.TryParse(value, out var host);
- return host!;
- }
-
- [Test]
- public async Task TheSpecifiedFormatIsHostSpacePort()
- {
- // "using the host port format … Make sure to separate the host and port with a space rather
- // than : because IPv6 addresses contain colons."
- MsspHost.TryParse("mud.example.org 4000", out var host);
-
- await Assert.That(host).IsNotNull();
- await Assert.That(host!.Host).IsEqualTo("mud.example.org");
- await Assert.That(host.Port).IsEqualTo(4000);
- await Assert.That(host.ToReferralString()).IsEqualTo("mud.example.org 4000");
- }
-
- [Test]
- public async Task AnIpV6ReferralIsParsedAndCanonicalised()
- {
- // The exact reason the specification chose a space. Two spellings of one address must become one
- // host, or a crawl visits it once per spelling its peers happen to use.
- MsspHost.TryParse("2001:0DB8:0000:0000:0000:0000:0000:0001 4201", out var verbose);
- MsspHost.TryParse("2001:db8::1 4201", out var compact);
-
- await Assert.That(verbose).IsNotNull();
- await Assert.That(verbose!.Host).IsEqualTo("2001:db8::1");
- await Assert.That(verbose).IsEqualTo(compact!);
- await Assert.That(verbose.ToString()).IsEqualTo("[2001:db8::1]:4201");
- }
-
- [Test]
- public async Task ABracketedIpV6LiteralIsAccepted()
- {
- MsspHost.TryParse("[2001:db8::1] 4201", out var host);
-
- await Assert.That(host).IsNotNull();
- await Assert.That(host!.Host).IsEqualTo("2001:db8::1");
- }
-
- [Test]
- public async Task TheColonFormIsToleratedOnlyWhereItCannotBeAnIpV6Address()
- {
- // Real servers emit host:port despite the specification. Accepting it recovers referrals that
- // would otherwise be lost…
- MsspHost.TryParse("mud.example.org:4000", out var name);
- await Assert.That(name).IsNotNull();
- await Assert.That(name!.Port).IsEqualTo(4000);
-
- // …but a string with more than one colon is far more likely to be a bare IPv6 address, and
- // splitting it at the last colon would silently rewrite it into a different address.
- await Assert.That(MsspHost.TryParse("2001:db8::1", out _)).IsFalse();
- await Assert.That(MsspHost.TryParse("2001:db8::1:4201", out _)).IsFalse();
- }
-
- [Test]
- [Arguments("")]
- [Arguments(" ")]
- [Arguments("mud.example.org")]
- [Arguments("mud.example.org 0")]
- [Arguments("mud.example.org 70000")]
- [Arguments("mud.example.org -1")]
- [Arguments("mud.example.org notaport")]
- [Arguments("http://mud.example.org/ 4000")]
- [Arguments("a stale line someone typed")]
- public async Task AMalformedReferralIsRejectedRatherThanGuessedAt(string value) =>
- await Assert.That(MsspHost.TryParse(value, out _)).IsFalse();
-
- [Test]
- public async Task HostNamesAreNormalisedSoOneServerIsOneEntry()
- {
- var spellings = new[]
- {
- "MUD.Example.ORG 4201",
- "mud.example.org 4201",
- "mud.example.org. 4201",
- "mud.example.org:4201",
- };
-
- var hosts = spellings.Select(Host).ToHashSet();
- await Assert.That(hosts.Count).IsEqualTo(1);
- }
-
- [Test]
- public async Task TheReferralListIsReadFromTheArrayAndDeduplicated()
- {
- var data = MsspWire.Report(
- ("REFERRAL",
- [
- "a.example.org 4000",
- "b.example.org 4000",
- "A.EXAMPLE.ORG 4000", // the same host, spelled differently
- "not a referral", // a stale line; dropped, not fatal
- "2001:db8::9 4201",
- ]));
-
- await Assert.That(data.Referrals.Select(r => r.ToReferralString()))
- .IsEquivalentTo(new[] { "a.example.org 4000", "b.example.org 4000", "2001:db8::9 4201" });
-
- // The raw strings stay readable, so a report can show what a server actually said.
- await Assert.That(data["REFERRAL"].Count).IsEqualTo(5);
- }
-
- [Test]
- public async Task PrivateAndLoopbackAddressesAreClassifiedAsUncrawlable()
- {
- await Assert.That(Host("127.0.0.1 4201").Scope).IsEqualTo(MsspHostScope.Loopback);
- await Assert.That(Host("::1 4201").Scope).IsEqualTo(MsspHostScope.Loopback);
- await Assert.That(Host("10.1.2.3 4201").Scope).IsEqualTo(MsspHostScope.Private);
- await Assert.That(Host("192.168.1.1 4201").Scope).IsEqualTo(MsspHostScope.Private);
- await Assert.That(Host("172.16.0.1 4201").Scope).IsEqualTo(MsspHostScope.Private);
- await Assert.That(Host("fd00::1 4201").Scope).IsEqualTo(MsspHostScope.Private);
-
- // The cloud metadata address, which is the one that matters.
- await Assert.That(Host("169.254.169.254 80").Scope).IsEqualTo(MsspHostScope.LinkLocal);
-
- await Assert.That(Host("198.51.100.7 4201").Scope).IsEqualTo(MsspHostScope.Global);
- await Assert.That(Host("mud.example.org 4201").Scope).IsEqualTo(MsspHostScope.Unresolved);
- }
-
- // ---- What the frontier does with them ----
-
- private static CrawlFrontier Frontier(CrawlOptions? options = null) =>
- new(options ?? new CrawlOptions());
-
- [Test]
- public async Task AReferralBackToTheReferrerIsRecognisedAndIgnored()
- {
- // The simplest cycle, and the commonest: two servers list each other. Neither is a fault.
- var frontier = Frontier();
- var a = Host("a.example.org 4000");
- var b = Host("b.example.org 4000");
-
- frontier.AddSeed(a, Now);
- await Assert.That(frontier.Discover(b, a, 0, Now)).IsEqualTo(DiscoveryVerdict.Added);
- await Assert.That(frontier.Discover(a, b, 1, Now)).IsEqualTo(DiscoveryVerdict.AlreadyKnown);
-
- await Assert.That(frontier.Records.Count).IsEqualTo(2);
- }
-
- [Test]
- public async Task AServerThatRefersToItselfIsCountedAndNotFollowed()
- {
- var frontier = Frontier();
- var a = Host("a.example.org 4000");
- frontier.AddSeed(a, Now);
-
- await Assert.That(frontier.Discover(a, a, 0, Now)).IsEqualTo(DiscoveryVerdict.SelfReferral);
- await Assert.That(frontier.Records.Count).IsEqualTo(1);
- }
-
- [Test]
- public async Task ALongerCycleTerminatesBecauseIdentityIsNormalised()
- {
- // A → B → C → A, with each hop spelling the next host differently. Identity is over the
- // normalised host, so the loop closes on the third hop instead of running for ever.
- var frontier = Frontier();
- var a = Host("a.example.org 4000");
- var b = Host("b.example.org 4000");
- var c = Host("c.example.org 4000");
-
- frontier.AddSeed(a, Now);
- frontier.Discover(b, a, 0, Now);
- frontier.Discover(c, b, 1, Now);
-
- await Assert.That(frontier.Discover(Host("A.Example.ORG. 4000"), c, 2, Now))
- .IsEqualTo(DiscoveryVerdict.AlreadyKnown);
- await Assert.That(frontier.Records.Count).IsEqualTo(3);
- }
-
- [Test]
- public async Task AHostReachedAgainByAShorterPathKeepsTheShorterDepth()
- {
- // Depth is what the cap is measured against, so a host first met at the limit would otherwise
- // never have its own referrals followed even after a shorter route to it turned up.
- var frontier = Frontier();
- var seed = Host("seed.example.org 4000");
- var far = Host("far.example.org 4000");
-
- frontier.AddSeed(seed, Now);
- frontier.Discover(far, seed, 3, Now);
- await Assert.That(frontier.Records.Single(r => r.Host == far).Depth).IsEqualTo(4);
-
- frontier.Discover(far, seed, 0, Now);
- await Assert.That(frontier.Records.Single(r => r.Host == far).Depth).IsEqualTo(1);
- }
-
- [Test]
- public async Task AReferralBeyondTheDepthLimitIsRefused()
- {
- var frontier = Frontier(new CrawlOptions { MaxDepth = 2 });
- frontier.AddSeed(Host("seed.example.org 4000"), Now);
-
- var referrer = Host("seed.example.org 4000");
- await Assert.That(frontier.Discover(Host("a 4000"), referrer, 1, Now)).IsEqualTo(DiscoveryVerdict.Added);
- await Assert.That(frontier.Discover(Host("b 4000"), referrer, 2, Now)).IsEqualTo(DiscoveryVerdict.TooDeep);
- }
-
- [Test]
- public async Task AReferralIntoPrivateSpaceIsRefusedUnlessTheOperatorAsksForIt()
- {
- var referrer = Host("stranger.example.org 4000");
-
- var guarded = Frontier();
- guarded.AddSeed(referrer, Now);
- await Assert.That(guarded.Discover(Host("169.254.169.254 80"), referrer, 0, Now))
- .IsEqualTo(DiscoveryVerdict.NotRoutable);
- await Assert.That(guarded.Discover(Host("127.0.0.1 4201"), referrer, 0, Now))
- .IsEqualTo(DiscoveryVerdict.NotRoutable);
-
- var permissive = new CrawlFrontier(new CrawlOptions { FollowPrivateAddresses = true });
- permissive.AddSeed(referrer, Now);
- await Assert.That(permissive.Discover(Host("127.0.0.1 4201"), referrer, 0, Now))
- .IsEqualTo(DiscoveryVerdict.Added);
- }
-
- [Test]
- public async Task ASeedIntoPrivateSpaceIsAcceptedBecauseTheOperatorSaidSo()
- {
- // The operator pointing this at their own test server is not the same thing as a stranger's
- // referral aiming it at a network it could not otherwise reach.
- var frontier = Frontier();
- await Assert.That(frontier.AddSeed(Host("127.0.0.1 4201"), Now)).IsEqualTo(DiscoveryVerdict.Added);
- }
-
- [Test]
- public async Task ReferralsAreNotFollowedAtAllWhenTheRunSaysNotTo()
- {
- var frontier = Frontier(new CrawlOptions { FollowReferrals = false });
- var a = Host("a.example.org 4000");
- frontier.AddSeed(a, Now);
-
- await Assert.That(frontier.Discover(Host("b.example.org 4000"), a, 0, Now))
- .IsEqualTo(DiscoveryVerdict.ReferralsDisabled);
- await Assert.That(frontier.Records.Count).IsEqualTo(1);
- }
-}
diff --git a/tests/SharpMUTerm.Crawler.Tests/SeedTests.cs b/tests/SharpMUTerm.Crawler.Tests/SeedTests.cs
deleted file mode 100644
index 6abb15c..0000000
--- a/tests/SharpMUTerm.Crawler.Tests/SeedTests.cs
+++ /dev/null
@@ -1,191 +0,0 @@
-using SharpMUTerm.Crawler;
-
-namespace SharpMUTerm.Crawler.Tests;
-
-///
-/// Where a run's starting hosts come from, and — more importantly — what it refuses to read.
-///
-public class SeedTests
-{
- private static string WriteTemp(string name, string content)
- {
- var directory = Path.Combine(Path.GetTempPath(), "sharpmuterm-crawler-tests", Guid.NewGuid().ToString("N"));
- Directory.CreateDirectory(directory);
- var path = Path.Combine(directory, name);
- File.WriteAllText(path, content);
- return path;
- }
-
- [Test]
- public async Task ASeedFileIsHostSpacePortWithCommentsIgnored()
- {
- var path = WriteTemp("seeds.txt", """
- # The hosts this run starts from.
- mud.example.org 4201
- other.example.net 4000 # trailing comment
-
- 2001:db8::1 4201
- legacy.example.com:23
- """);
-
- try
- {
- var seeds = Seeds.FromFile(path);
-
- await Assert.That(seeds.Rejected).IsEmpty();
- await Assert.That(seeds.Hosts.Select(h => h.ToReferralString())).IsEquivalentTo(new[]
- {
- "mud.example.org 4201",
- "other.example.net 4000",
- "2001:db8::1 4201",
- "legacy.example.com 23",
- });
- }
- finally
- {
- Directory.Delete(Path.GetDirectoryName(path)!, recursive: true);
- }
- }
-
- [Test]
- public async Task ABadSeedLineIsReportedRatherThanStoppingTheRun()
- {
- var path = WriteTemp("seeds.txt", """
- good.example.org 4201
- this is not a host
- good.example.org 4201
- """);
-
- try
- {
- var seeds = Seeds.FromFile(path);
-
- await Assert.That(seeds.Hosts.Count).IsEqualTo(1); // the duplicate collapses
- await Assert.That(seeds.Rejected).IsEquivalentTo(new[] { "this is not a host" });
- }
- finally
- {
- Directory.Delete(Path.GetDirectoryName(path)!, recursive: true);
- }
- }
-
- [Test]
- public async Task AWorldsFileYieldsOnlyHostsAndPortsAndNothingElse()
- {
- // A realistic SharpMUTerm configuration, including the fields this tool must never look at.
- var path = WriteTemp("config.json", """
- {
- "version": 3,
- "worlds": [
- {
- "name": "Corvid",
- "host": "corvid.example.org",
- "port": 4201,
- "useTls": false,
- "characters": [
- { "name": "Ann", "login": "connect Ann hunter2", "passwordRef": "9f2a-…" }
- ]
- },
- {
- "name": "Second",
- "host": "second.example.net",
- "port": 2000,
- "characters": []
- },
- {
- "name": "No port",
- "host": "broken.example.org"
- }
- ]
- }
- """);
-
- try
- {
- var seeds = Seeds.FromWorldsFile(path);
-
- await Assert.That(seeds.Hosts.Select(h => h.ToReferralString()))
- .IsEquivalentTo(new[] { "corvid.example.org 4201", "second.example.net 2000" });
-
- // The world with no port is reported by name; nothing about its characters is read at all.
- await Assert.That(seeds.Rejected).IsEquivalentTo(new[] { "No port: no host or port" });
- await Assert.That(string.Join("|", seeds.Rejected)).DoesNotContain("hunter2");
- await Assert.That(string.Join("|", seeds.Rejected)).DoesNotContain("Ann");
- }
- finally
- {
- Directory.Delete(Path.GetDirectoryName(path)!, recursive: true);
- }
- }
-
- [Test]
- [Arguments("GetFolderPath")]
- [Arguments("SpecialFolder")]
- [Arguments("XDG_CONFIG_HOME")]
- [Arguments("secrets.json")]
- public async Task TheCrawlerCannotReachForTheUsersConfigurationDirectory(string forbidden)
- {
- // The guarantee this tool makes is that it reads nothing it was not explicitly pointed at, and
- // that guarantee is worth a test that cannot be satisfied by a careful reading. Every method a
- // program calls leaves its name in the assembly's metadata, and every string literal it holds is
- // in there too — so if a future change ever resolves the configuration directory, or names the
- // secrets file, the name is in this file and this fails.
- var bytes = await File.ReadAllBytesAsync(typeof(Seeds).Assembly.Location);
- var needle = System.Text.Encoding.UTF8.GetBytes(forbidden);
-
- var found = false;
- for (var i = 0; i + needle.Length <= bytes.Length && !found; i++)
- {
- found = bytes.AsSpan(i, needle.Length).SequenceEqual(needle);
- }
-
- await Assert.That(found).IsFalse()
- .Because($"the crawler assembly refers to \"{forbidden}\"; it must not know where the user's "
- + "configuration lives, only what it was handed on the command line");
- }
-
- // ---- The command line ----
-
- [Test]
- public async Task ASeedIsTakenFromTheCommandLine()
- {
- var parsed = CommandLine.Parse(["--seed", "mud.example.org:4201", "--seed", "other.example.net 23"]);
-
- await Assert.That(parsed.Error).IsNull();
- await Assert.That(parsed.Options!.Seeds.Select(h => h.ToReferralString()))
- .IsEquivalentTo(new[] { "mud.example.org 4201", "other.example.net 23" });
- }
-
- [Test]
- public async Task PolitenessSettingsCanBeLoosenedOnlyDeliberately()
- {
- var defaults = CommandLine.Parse(["--seed", "a.example.org:1"]).Options!;
- await Assert.That(defaults.MaxConcurrency).IsEqualTo(4);
- await Assert.That(defaults.RevisitInterval).IsEqualTo(TimeSpan.FromHours(24));
-
- var loosened = CommandLine.Parse(
- ["--seed", "a.example.org:1", "--concurrency", "2", "--revisit", "48", "--max-hosts", "10"]).Options!;
- await Assert.That(loosened.MaxConcurrency).IsEqualTo(2);
- await Assert.That(loosened.RevisitInterval).IsEqualTo(TimeSpan.FromHours(48));
- await Assert.That(loosened.MaxHosts).IsEqualTo(10);
- }
-
- [Test]
- public async Task ASettingThatCouldOnlyBeATypoIsRefusedBeforeAnythingReachesTheNetwork()
- {
- await Assert.That(CommandLine.Parse(["--seed", "a.example.org:1", "--concurrency", "0"]).Error).IsNotNull();
- await Assert.That(CommandLine.Parse(["--seed", "a.example.org:1", "--max-hosts", "0"]).Error).IsNotNull();
- await Assert.That(CommandLine.Parse(["--nonsense"]).Error).IsNotNull();
- await Assert.That(CommandLine.Parse(["--seed", "not a host at all"]).Error).IsNotNull();
- }
-
- [Test]
- public async Task TheHelpTextSaysWhatTheToolWillAndWillNotDo()
- {
- // A server operator who finds this in their logs and searches for the name should reach a tool
- // that states plainly that it never logs in.
- await Assert.That(CommandLine.Parse(["--help"]).WantsHelp).IsTrue();
- await Assert.That(CommandLine.Usage).Contains("never logs in and never sends a command");
- await Assert.That(CommandLine.Usage).Contains("SHARPMUTERM-MSSPCRAWLER");
- }
-}
diff --git a/tests/SharpMUTerm.Crawler.Tests/SharpMUTerm.Crawler.Tests.csproj b/tests/SharpMUTerm.Crawler.Tests/SharpMUTerm.Crawler.Tests.csproj
deleted file mode 100644
index 087ff68..0000000
--- a/tests/SharpMUTerm.Crawler.Tests/SharpMUTerm.Crawler.Tests.csproj
+++ /dev/null
@@ -1,19 +0,0 @@
-
-
-
- Exe
- SharpMUTerm.Crawler.Tests
- SharpMUTerm.Crawler.Tests
- false
- true
-
-
-
-
-
-
-
-
-
-
-
diff --git a/tests/SharpMUTerm.Crawler.Tests/Support/FakeProbe.cs b/tests/SharpMUTerm.Crawler.Tests/Support/FakeProbe.cs
deleted file mode 100644
index abba4e6..0000000
--- a/tests/SharpMUTerm.Crawler.Tests/Support/FakeProbe.cs
+++ /dev/null
@@ -1,115 +0,0 @@
-using SharpMUTerm.Core.Telnet.Mssp;
-using SharpMUTerm.Crawler.Model;
-using SharpMUTerm.Crawler.Probing;
-
-namespace SharpMUTerm.Crawler.Tests.Support;
-
-///
-/// A probe that answers from a script instead of a socket, so the crawl loop can be tested without a
-/// network. Records every host it was asked about, in order, and how many were in flight at once.
-///
-internal sealed class FakeProbe(TimeProvider time) : IMsspProbe
-{
- private readonly Lock _gate = new();
- private readonly List _visited = [];
- private readonly Dictionary> _answers = [];
-
- private int _inFlight;
-
- /// Every host probed, in the order the loop reached them.
- public IReadOnlyList Visited
- {
- get
- {
- lock (_gate)
- {
- return [.. _visited];
- }
- }
- }
-
- /// The most connections that were open at the same moment.
- public int PeakConcurrency { get; private set; }
-
- /// What an unscripted host answers with. Defaults to a server that has no MSSP.
- public CrawlOutcome DefaultOutcome { get; set; } = CrawlOutcome.NoMssp;
-
- /// Held open until released, so a test can observe several probes in flight at once.
- public TaskCompletionSource? Gate { get; set; }
-
- /// Answers with a report referring to .
- public FakeProbe Referring(MsspHost host, params MsspHost[] referrals)
- {
- var data = MsspWire.Report(
- ("NAME", [$"Server at {host.Host}"]),
- ("REFERRAL", referrals.Select(r => r.ToReferralString()).ToArray()));
-
- lock (_gate)
- {
- _answers[host] = probed => new ProbeResult
- {
- Host = probed,
- Outcome = CrawlOutcome.MsspReceived,
- ObservedAt = time.GetUtcNow(),
- Data = data,
- };
- }
-
- return this;
- }
-
- /// Answers with a specific outcome and no data.
- public FakeProbe Answering(MsspHost host, CrawlOutcome outcome, string? error = null)
- {
- lock (_gate)
- {
- _answers[host] = probed => new ProbeResult
- {
- Host = probed,
- Outcome = outcome,
- ObservedAt = time.GetUtcNow(),
- Error = error,
- };
- }
-
- return this;
- }
-
- public async Task ProbeAsync(MsspHost host, CancellationToken cancellationToken)
- {
- Func? answer;
- lock (_gate)
- {
- _visited.Add(host);
- _inFlight++;
- PeakConcurrency = Math.Max(PeakConcurrency, _inFlight);
- _answers.TryGetValue(host, out answer);
- }
-
- try
- {
- if (Gate is { } gate)
- {
- await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
- }
- else
- {
- await Task.Yield();
- }
-
- return answer?.Invoke(host) ?? new ProbeResult
- {
- Host = host,
- Outcome = DefaultOutcome,
- ObservedAt = time.GetUtcNow(),
- };
- }
- finally
- {
- lock (_gate)
- {
- _inFlight--;
- }
- }
- }
-}
diff --git a/tests/SharpMUTerm.Crawler.Tests/Support/ManualTimeProvider.cs b/tests/SharpMUTerm.Crawler.Tests/Support/ManualTimeProvider.cs
deleted file mode 100644
index 597993a..0000000
--- a/tests/SharpMUTerm.Crawler.Tests/Support/ManualTimeProvider.cs
+++ /dev/null
@@ -1,175 +0,0 @@
-namespace SharpMUTerm.Crawler.Tests.Support;
-
-///
-/// A clock and timer source a test moves by hand.
-///
-/// It exists so a rate limit can be asserted rather than waited for. A limiter tested with
-/// real time is a test that sleeps for its own interval and then races the machine it runs on: slow
-/// enough to be annoying, flaky enough to be disabled, and it proves nothing about the interval it did
-/// not sleep for. moves the clock and fires every timer it passes, on the calling
-/// thread, so the effect of a wait lands before the next line of the test.
-///
-///
-internal sealed class ManualTimeProvider : TimeProvider
-{
- private readonly List _timers = [];
- private readonly Lock _gate = new();
-
- private DateTimeOffset _now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
-
- public override DateTimeOffset GetUtcNow()
- {
- lock (_gate)
- {
- return _now;
- }
- }
-
- public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
- {
- var timer = new ManualTimer(this, callback, state);
- lock (_gate)
- {
- _timers.Add(timer);
- }
-
- timer.Change(dueTime, period);
- return timer;
- }
-
- /// Moves the clock on by , firing every timer that comes due.
- public void Advance(TimeSpan by)
- {
- ManualTimer[] due;
- lock (_gate)
- {
- _now += by;
- // A firing callback may arm another timer, so fire from a snapshot and let anything new
- // wait for the next Advance.
- due = [.. _timers];
- }
-
- var now = GetUtcNow();
- foreach (var timer in due)
- {
- timer.FireIfDue(now);
- }
- }
-
- private void Forget(ManualTimer timer)
- {
- lock (_gate)
- {
- _timers.Remove(timer);
- }
- }
-
- private sealed class ManualTimer(ManualTimeProvider owner, TimerCallback callback, object? state) : ITimer
- {
- private DateTimeOffset? _dueAt;
-
- public bool Change(TimeSpan dueTime, TimeSpan period)
- {
- _dueAt = dueTime == Timeout.InfiniteTimeSpan ? null : owner.GetUtcNow() + dueTime;
- return true;
- }
-
- public void FireIfDue(DateTimeOffset now)
- {
- if (_dueAt is { } due && now >= due)
- {
- _dueAt = null;
- callback(state);
- }
- }
-
- public void Dispose()
- {
- _dueAt = null;
- owner.Forget(this);
- }
-
- public ValueTask DisposeAsync()
- {
- Dispose();
- return ValueTask.CompletedTask;
- }
- }
-}
-
-///
-/// A clock that runs on demand: a delay does not wait, it moves the clock forward by the amount asked
-/// for and returns.
-///
-/// This is what lets the crawl loop — which really does wait on its rate limiter — be tested
-/// without sleeping. cannot serve here: the loop decides how long to
-/// wait from inside itself, so a test cannot know how far to advance the clock without reimplementing
-/// the thing it is testing. Here time passes exactly as the code under test asks it to, and
-/// afterwards is the true elapsed total — which is what the time-cap test
-/// asserts against.
-///
-///
-internal sealed class VirtualTimeProvider : TimeProvider
-{
- private readonly Lock _gate = new();
-
- private DateTimeOffset _now = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
-
- public override DateTimeOffset GetUtcNow()
- {
- lock (_gate)
- {
- return _now;
- }
- }
-
- /// Moves the clock without any timer being involved — how a test sets up "a day later".
- public void Advance(TimeSpan by)
- {
- lock (_gate)
- {
- _now += by;
- }
- }
-
- public override ITimer CreateTimer(TimerCallback callback, object? state, TimeSpan dueTime, TimeSpan period)
- => new InstantTimer(this, callback, state, dueTime);
-
- private sealed class InstantTimer : ITimer
- {
- private readonly CancellationTokenSource _cancel = new();
-
- public InstantTimer(VirtualTimeProvider owner, TimerCallback callback, object? state, TimeSpan dueTime)
- {
- if (dueTime == Timeout.InfiniteTimeSpan)
- {
- return;
- }
-
- // Queued rather than run inline: Task.Delay builds its timer inside its own constructor, and
- // completing it before that returns deadlocks. The clock still moves by the full amount, so
- // the code under test observes exactly the interval it asked to wait.
- _ = Task.Run(async () =>
- {
- await Task.Yield();
- if (_cancel.IsCancellationRequested)
- {
- return;
- }
-
- owner.Advance(dueTime);
- callback(state);
- });
- }
-
- public bool Change(TimeSpan dueTime, TimeSpan period) => true;
-
- public void Dispose() => _cancel.Cancel();
-
- public ValueTask DisposeAsync()
- {
- Dispose();
- return ValueTask.CompletedTask;
- }
- }
-}
diff --git a/tests/SharpMUTerm.Tui.Tests/MsspScreenTests.cs b/tests/SharpMUTerm.Tui.Tests/MsspScreenTests.cs
new file mode 100644
index 0000000..d92770b
--- /dev/null
+++ b/tests/SharpMUTerm.Tui.Tests/MsspScreenTests.cs
@@ -0,0 +1,550 @@
+using System.Text.RegularExpressions;
+using SharpConsoleUI.Drivers;
+using SharpMUTerm.Core.Configuration;
+using SharpMUTerm.Core.Telnet.Mssp;
+using SharpMUTerm.Graphics;
+using SharpMUTerm.Tui;
+
+namespace SharpMUTerm.Tui.Tests;
+
+///
+/// The F5 ▸ i MSSP report: that the key reaches it, that the three states are distinguishable
+/// on a rendered frame, that a report is drawn as the lists it is, and that no value a stranger can
+/// send widens or breaks a row.
+///
+///
+/// Serialised where a frame is rendered, for the same reason every snapshot test in this suite is:
+/// capturing a frame redirects Console.Out, and that is process-global.
+///
+[NotInParallel]
+public class MsspScreenTests
+{
+ private const int Width = 120;
+ private const int Height = 34;
+
+ private static readonly DateTimeOffset Noon = new(2026, 7, 30, 12, 0, 0, TimeSpan.Zero);
+
+ private static readonly TerminalCapabilities Headless =
+ new(GraphicsProtocol.None, supportsTrueColor: true, supportsKittyGraphics: false, supportsSixel: false);
+
+ ///
+ /// A rendered frame, decoded to the cells the driver was handed. Assertions go through the grid and
+ /// never through the raw ANSI: one drawn phrase is several SGR runs, so Contains on the frame
+ /// string can miss text that is plainly on the screen — and, worse, can pass on text that is not.
+ ///
+ private static IReadOnlyList Frame(string view, int width = Width, int height = Height)
+ {
+ Console.SetIn(TextReader.Null);
+ var app = new SharpMUTermApp(DemoScene.Build(), Headless, new HeadlessConsoleDriver(width, height));
+ return FrameGrid.Decode(app.RenderSnapshot(view), width, height);
+ }
+
+ /// The whole frame as one string, for the assertions that only ask whether a phrase is on it.
+ private static string Screen(string view, int width = Width, int height = Height) =>
+ string.Join("\n", Frame(view, width, height));
+
+ private static WorldDefinition World() =>
+ new() { Name = "Aetherfall", Host = "aetherfall.mux", Port = 4201, UseTls = true };
+
+ private static MsspObservation Observed(MsspData report, DateTimeOffset? at = null) =>
+ new("aetherfall.mux:4201", Noon, report, at ?? Noon);
+
+ private static MsspData Report(params (string Name, string[] Values)[] entries) =>
+ MsspData.From(entries.Select(e =>
+ new KeyValuePair>(e.Name, e.Values)));
+
+ ///
+ /// A body block as plain text: markup tags removed, escaped brackets put back. The inverse of what
+ /// counts, written the same way — the escapes are guarded
+ /// with two characters no value can carry (control characters never survive the renderer) before
+ /// the tag pattern runs.
+ ///
+ private static List Plain(IEnumerable lines) =>
+ lines.Select(StripMarkup).ToList();
+
+ private static string StripMarkup(string markup)
+ {
+ const char openMark = '\u0001';
+ const char closeMark = '\u0002';
+ var guarded = markup.Replace("[[", openMark.ToString()).Replace("]]", closeMark.ToString());
+ var stripped = Regex.Replace(guarded, @"\[[^\[\]]*\]", string.Empty);
+ return stripped.Replace(openMark, '[').Replace(closeMark, ']');
+ }
+
+ ///
+ /// The label column of a plain row — three cells of mark and gap, then the fixed-width name field.
+ /// Rows are read by column because a substring match crosses fields: transport contains
+ /// port, and a test that matched the wrong row would be asserting about the wrong thing while
+ /// passing.
+ ///
+ private static string LabelOf(string row)
+ {
+ const int labelAt = 3;
+ return row.Length <= labelAt
+ ? string.Empty
+ : row[labelAt..Math.Min(row.Length, labelAt + MsspScreenRenderer.NameWidth)].Trim();
+ }
+
+ // ---- Reaching it ----
+
+ [Test]
+ public async Task TheWorldsScreenAdvertisesTheInfoKeyAndSaysWhichWorldItActsOn()
+ {
+ var frame = Screen("worlds");
+
+ // Derived from the model, never written: the hint and the drawn row both come from the same
+ // ScreenButton, so a screen cannot advertise a key it does not answer.
+ await Assert.That(frame).Contains("i info");
+ await Assert.That(frame).Contains($"{ScreenChrome.InfoWords} Aetherfall");
+ }
+
+ [Test]
+ public async Task PressingIOnTheSelectedWorldOpensItsReport()
+ {
+ // The snapshot view drives the real key through the real button; nothing about the route is faked.
+ var frame = Screen("mssp");
+
+ await Assert.That(frame).Contains(MsspScreenRenderer.Title);
+ await Assert.That(frame).Contains("Aetherfall");
+ await Assert.That(frame).Contains("Esc back");
+
+ // And it is a report, not a form: the world list it came from is gone from the screen.
+ await Assert.That(frame).DoesNotContain("Worlds & Characters");
+ }
+
+ [Test]
+ public async Task EscapeFromTheReportGoesBackToTheWorldItWasOpenedFrom()
+ {
+ Console.SetIn(TextReader.Null);
+ var app = new SharpMUTermApp(DemoScene.Build(), Headless, new HeadlessConsoleDriver(Width, Height));
+ app.RenderSnapshot("mssp");
+
+ // One Esc pops the report; the screen behind comes back with its own cursor and hints. A second
+ // Esc leaves the settings altogether, which is the layering Esc has everywhere else in the app.
+ await Assert.That(app.Settings.IsShowingDetail).IsTrue();
+ app.Settings.SimulateKey(new ConsoleKeyInfo('\0', ConsoleKey.Escape, false, false, false));
+
+ await Assert.That(app.Settings.IsShowingDetail).IsFalse();
+ await Assert.That(app.Settings.IsOpen).IsTrue();
+ await Assert.That(app.RenderWholeFrame()).Contains("Worlds & Characters");
+ }
+
+ [Test]
+ public async Task ADeletionMadeBeforeOpeningAReportIsStillReviewedOnTheWayOut()
+ {
+ // Closing from *over* a report has to review the screen's deletions, not the report's. A report
+ // carries an edit log of its own and it is always empty, so reading the top of the stack would
+ // silently drop a world someone had just taken out — the one class of edit this project asks
+ // about precisely because it cannot be retyped.
+ Console.SetIn(TextReader.Null);
+ var app = new SharpMUTermApp(DemoScene.Build(), Headless, new HeadlessConsoleDriver(Width, Height));
+ app.RenderSnapshot("worlds");
+
+ app.Settings.SimulateKey(Key(ConsoleKey.Delete));
+ app.Settings.SimulateKey(new ConsoleKeyInfo('i', ConsoleKey.I, false, false, false));
+ await Assert.That(app.Settings.IsShowingDetail).IsTrue();
+
+ // F5 is a global shortcut, not one of the screen's own keys, so it is pressed the way the
+ // framework would deliver it — through the app rather than into the overlay.
+ app.SimulateKey(new ConsoleKeyInfo('\0', ConsoleKey.F5, false, false, false));
+
+ await Assert.That(app.Settings.IsOpen).IsFalse();
+ await Assert.That(app.Settings.Review.IsOpen).IsTrue();
+ await Assert.That(string.Join("\n", app.Settings.Review.Lines)).Contains("Aetherfall");
+ }
+
+ // ---- The key's shape and scope ----
+
+ ///
+ /// Both targeted keys trail the pane's one cursor stop and neither is one. This is the invariant
+ /// is built on — an action with a target must not steal the cursor
+ /// from the thing it acts on — and the reason an INFO chip would have been wrong: reaching
+ /// it with ↑↓ walks the selection to the last world, so it could only ever have reported on that one.
+ ///
+ [Test]
+ public async Task TheInfoRowIsDrawnAndIsNotSomewhereTheCursorCanGo()
+ {
+ var worlds = new List { World(), new() { Name = "Grapevine" } };
+ var model = WorldsScreenRenderer.Model(worlds, [], 0, 0, 0, _ => { });
+
+ // Two worlds, then [+ world]; the `i` and `Del` rows are drawn past the end of the stops.
+ await Assert.That(model.RowCount(WorldsScreenRenderer.WorldsPane)).IsEqualTo(5);
+ await Assert.That(model.Sizes[WorldsScreenRenderer.WorldsPane]).IsEqualTo(3);
+ await Assert.That(model.HasDetailRow).IsTrue();
+
+ // And the drawn column says what the key would act on, which is why nothing is lost by it not
+ // being a chip.
+ var column = WorldsScreenRenderer.WorldsColumn(worlds, 0, info: true);
+ await Assert.That(Plain(column).Any(l => l.Contains($"{ScreenChrome.InfoWords} Aetherfall"))).IsTrue();
+ }
+
+ [Test]
+ public async Task AProjectionWithNowhereToOpenAReportOffersNeitherTheRowNorTheHint()
+ {
+ // The renderer is pure and cannot put a screen on the screen, so a caller that supplies no action
+ // gets no `i` row — and therefore no `i info` hint, because the hint is derived from the row.
+ var model = WorldsScreenRenderer.Model(new List { World() }, [], 0, 0);
+
+ await Assert.That(model.HasDetailRow).IsFalse();
+ await Assert.That(WorldsScreenRenderer.HeaderLine(Width, model)).DoesNotContain(ScreenChrome.DetailHint);
+ }
+
+ [Test]
+ public async Task TheKeyRunsOnAWorldRowAndIsDeclinedEverywhereElse()
+ {
+ var opened = new List();
+ var worlds = new List { World(), new() { Name = "Grapevine" } };
+ var session = new SettingsSession(selection => WorldsScreenRenderer.Model(
+ worlds,
+ [],
+ selection.SelectionIn(WorldsScreenRenderer.WorldsPane),
+ selection.SelectionIn(WorldsScreenRenderer.CharactersPane),
+ selection.SelectionIn(WorldsScreenRenderer.TriggerSetsPane),
+ opened.Add));
+
+ // On the second world's row: opens that world, not the selected-at-seed one.
+ session.Selection.Seed(WorldsScreenRenderer.WorldsPane, 1);
+ await Assert.That(session.Handle(Key(ConsoleKey.I))).IsEqualTo(ScreenAction.Consumed);
+ await Assert.That(opened).IsEquivalentTo(new[] { 1 });
+
+ // On [+ world] — a button row, not a list row — it declines, exactly as Delete does there.
+ session.Selection.Seed(WorldsScreenRenderer.WorldsPane, 2);
+ await Assert.That(session.Handle(Key(ConsoleKey.I))).IsEqualTo(ScreenAction.None);
+
+ // In the CHARACTERS pane, which offers no report, the letter is not ours at all — it must fall
+ // through rather than be swallowed, or a pane that does nothing with `i` would eat it silently.
+ session.Selection.FocusPane(WorldsScreenRenderer.CharactersPane);
+ await Assert.That(session.Handle(Key(ConsoleKey.I))).IsEqualTo(ScreenAction.None);
+ await Assert.That(opened).IsEquivalentTo(new[] { 1 });
+ }
+
+ [Test]
+ public async Task TheLetterIsStillATypedCharacterWhileAFieldIsOpen()
+ {
+ // `i` is an ordinary letter, and the only thing that makes it safe as a command is that an open
+ // field edit takes the whole keyboard several branches earlier. Without that, a world could not
+ // be named `Riverside`.
+ var opened = new List();
+ var worlds = new List { new() { Name = "Old", Host = "h", Port = 1 } };
+ var session = new SettingsSession(selection => WorldsScreenRenderer.Model(
+ worlds,
+ [],
+ selection.SelectionIn(WorldsScreenRenderer.WorldsPane),
+ selection.SelectionIn(WorldsScreenRenderer.CharactersPane),
+ selection.SelectionIn(WorldsScreenRenderer.TriggerSetsPane),
+ opened.Add));
+
+ session.Handle(Key(ConsoleKey.Enter));
+ foreach (var ch in "Riverside")
+ {
+ session.Handle(new ConsoleKeyInfo(ch, ConsoleKey.None, false, false, false));
+ }
+
+ session.Handle(Key(ConsoleKey.Enter));
+
+ // Contains rather than equals: ⏎ opens the field on its existing text, and where the caret lands
+ // in it is the edit buffer's business and not this test's. What matters is that all nine letters
+ // — the `i` among them — went into the name, and that none of them opened a report.
+ await Assert.That(worlds[0].Name).Contains("Riverside");
+ await Assert.That(opened).IsEmpty();
+ }
+
+ [Test]
+ public async Task OpeningAReportPersistsNothing()
+ {
+ // Every other button on these screens is an edit and is written to disk the moment it is
+ // accepted. This one is navigation: routing it through ScreenEdits would write config.json and
+ // re-periodise every running timer each time somebody looked at a world.
+ var saves = 0;
+ var worlds = new List { World() };
+ var session = new SettingsSession(
+ selection => WorldsScreenRenderer.Model(
+ worlds,
+ [],
+ selection.SelectionIn(WorldsScreenRenderer.WorldsPane),
+ selection.SelectionIn(WorldsScreenRenderer.CharactersPane),
+ selection.SelectionIn(WorldsScreenRenderer.TriggerSetsPane),
+ _ => { }),
+ () => saves++);
+
+ session.Handle(Key(ConsoleKey.I));
+
+ await Assert.That(saves).IsEqualTo(0);
+ await Assert.That(session.Edits.HasDeletions).IsFalse();
+ }
+
+ private static ConsoleKeyInfo Key(ConsoleKey key) => new('\0', key, false, false, false);
+
+ // ---- The three states ----
+
+ [Test]
+ public async Task AWorldNothingHasConnectedToSaysSoRatherThanShowingNothing()
+ {
+ var frame = Screen("mssp-never");
+
+ await Assert.That(frame).Contains("connect once and this fills in");
+ await Assert.That(frame).DoesNotContain("does not publish MSSP");
+
+ // And it still shows what the client knows without asking anybody, which is what stops the
+ // screen reading as broken.
+ await Assert.That(frame).Contains("aetherfall.mux:4201");
+ }
+
+ [Test]
+ public async Task AServerThatAnsweredAndPublishesNothingIsNotTheSameEmptyScreen()
+ {
+ var frame = Screen("mssp-none");
+
+ await Assert.That(frame).Contains("does not publish MSSP");
+ await Assert.That(frame).Contains("It is optional");
+ await Assert.That(frame).DoesNotContain("connect once and this fills in");
+
+ // It says when we last reached the server, because "we asked and it said nothing" is only a
+ // claim worth making if the asking is dated.
+ await Assert.That(frame).Contains("last seen");
+ }
+
+ [Test]
+ public async Task AReportIsDatedSoAStalePlayerCountIsNotPresentedAsCurrent()
+ {
+ var week = Plain(MsspScreenRenderer.Body(
+ World(),
+ Observed(Report(("PLAYERS", ["37"])), Noon.AddDays(-7)),
+ Noon,
+ Width));
+
+ await Assert.That(week.Any(l => l.Contains("captured") && l.Contains("7 days ago"))).IsTrue();
+ await Assert.That(week.Any(l => l.Contains("2026-07-23"))).IsTrue();
+ }
+
+ // ---- What it shows ----
+
+ [Test]
+ public async Task AMultiValuedVariableIsDrawnAsTheListItIsAndNotAsOneOfItsValues()
+ {
+ var rows = Plain(MsspScreenRenderer.Body(
+ World(), Observed(Report(("PORT", ["80", "23", "4201"]))), Noon, Width));
+
+ // All three, in wire order, on three rows — least to most relevant, which is the order the
+ // specification gives them meaning in. A model keeping one value per variable would print a
+ // server's *least* preferred port and call it the port; one joining them would lose the ordering.
+ // Matched on the label *column*, not on a substring: `transport` contains `port`, and a test
+ // that found the wrong row would have been asserting about the world's TLS setting.
+ var at = rows.FindIndex(l => LabelOf(l) == "port");
+ await Assert.That(at).IsGreaterThanOrEqualTo(0);
+ await Assert.That(rows[at].TrimEnd()).EndsWith("80", StringComparison.Ordinal);
+ await Assert.That(rows[at + 1].TrimEnd()).EndsWith("23", StringComparison.Ordinal);
+ await Assert.That(rows[at + 2].TrimEnd()).EndsWith("4201", StringComparison.Ordinal);
+
+ // The name is printed once, so three values read as one variable rather than as three.
+ await Assert.That(LabelOf(rows[at + 1])).IsEmpty();
+ }
+
+ [Test]
+ public async Task EverythingTheServerSentIsShownAndTheUnofficialHalfIsMarked()
+ {
+ var rows = Plain(MsspScreenRenderer.Body(
+ World(),
+ Observed(Report(
+ ("NAME", ["Corvid"]),
+ ("ANSI", ["1"]),
+ ("PUEBLO", ["1"]),
+ ("CORVID SPECIFIC", ["nevermore"]))),
+ Noon,
+ Width));
+
+ var text = string.Join("\n", rows);
+ await Assert.That(text).Contains(MsspScreenRenderer.EverythingElse);
+ await Assert.That(text).Contains("ANSI");
+ await Assert.That(text).Contains("PUEBLO");
+ await Assert.That(text).Contains("CORVID SPECIFIC");
+ await Assert.That(text).Contains("nevermore");
+
+ // Official and unofficial are both visible and are told apart. ANSI is in the specification's
+ // tables; PUEBLO looks every bit as standard and is not, which is exactly why the reader cannot
+ // be left to tell from the name.
+ await Assert.That(rows.Single(l => l.Contains("PUEBLO")).TrimStart())
+ .StartsWith(MsspScreenRenderer.UnofficialMark, StringComparison.Ordinal);
+ await Assert.That(rows.Single(l => l.Contains(" ANSI")).TrimStart())
+ .DoesNotStartWith(MsspScreenRenderer.UnofficialMark);
+ await Assert.That(text).Contains(MsspScreenRenderer.UnofficialLegend);
+ }
+
+ [Test]
+ public async Task AMinusOneWorldCountReadsAsUnknownRatherThanAsMinusOne()
+ {
+ var rows = Plain(MsspScreenRenderer.Body(
+ World(), Observed(Report(("ROOMS", ["-1"]))), Noon, Width));
+
+ await Assert.That(rows.Any(l => l.Contains("ROOMS") && l.Contains(MsspScreenRenderer.Unavailable)))
+ .IsTrue();
+ await Assert.That(rows.Any(l => l.Contains("-1"))).IsFalse();
+ }
+
+ [Test]
+ public async Task AVariableTheServerNeverMentionedReadsDifferentlyFromOneItSentEmpty()
+ {
+ // Two different absences and the screen distinguishes them: "this server cannot tell you" is a
+ // fact about the server, "it never came up" is a fact about the report.
+ var rows = Plain(MsspScreenRenderer.Body(
+ World(), Observed(Report(("CONTACT", []))), Noon, Width));
+
+ await Assert.That(rows.Any(l => l.Contains("contact") && l.Contains(MsspScreenRenderer.Unavailable)))
+ .IsTrue();
+ await Assert.That(rows.Any(l => l.Contains("website") && l.Contains(MsspScreenRenderer.Unreported)))
+ .IsTrue();
+ }
+
+ // ---- Hostile values ----
+
+ [Test]
+ public async Task NoValueAStrangerCanSendMakesARowWiderThanTheScreen()
+ {
+ var hostile = Report(
+ ("NAME", [new string('W', 5000)]),
+ ("WEBSITE", ["https://" + new string('x', 900)]),
+ ("CONTACT", [new string('あ', 400)]));
+
+ foreach (var width in new[] { 80, 100, 120, 160 })
+ {
+ var rows = MsspScreenRenderer.Body(World(), Observed(hostile), Noon, width);
+ foreach (var row in rows)
+ {
+ await Assert.That(MarkupText.VisibleLength(row)).IsLessThanOrEqualTo(width)
+ .Because($"a row must fit {width} columns, and this one is off the wire");
+ }
+ }
+ }
+
+ [Test]
+ public async Task AValueCarryingMarkupCannotOpenATagOfItsOwn()
+ {
+ // A world's value is escaped, so `[bold red]` is text rather than a colour — and, more to the
+ // point, an unbalanced `[` cannot eat the rest of the row.
+ var rows = MsspScreenRenderer.Body(
+ World(),
+ Observed(Report(("NAME", ["[bold #ff0000 on #ff0000]owned"]), ("STATUS", ["[/]["]))),
+ Noon,
+ Width);
+
+ var text = string.Join("\n", rows);
+ await Assert.That(text).Contains("[[bold #ff0000 on #ff0000]]owned");
+ await Assert.That(Plain(rows).Any(l => l.Contains("[/]["))).IsTrue();
+ }
+
+ [Test]
+ public async Task AValueCarryingControlCharactersCannotAddOrShiftARow()
+ {
+ // A raw newline inside one value would end its row early and put a fragment of a stranger's text
+ // on a line of its own, below the row it belongs to and outside the column it was measured for.
+ // An ESC would be worse: the frame is ANSI.
+ var clean = MsspScreenRenderer.Body(
+ World(), Observed(Report(("NAME", ["ordinary"]))), Noon, Width);
+ var nasty = MsspScreenRenderer.Body(
+ World(), Observed(Report(("NAME", ["a\nb\rc[31md\te"]))), Noon, Width);
+
+ await Assert.That(nasty).Count().IsEqualTo(clean.Count);
+ foreach (var row in nasty)
+ {
+ await Assert.That(row.Any(char.IsControl)).IsFalse();
+ }
+ }
+
+ [Test]
+ public async Task AVariableNameCarryingMarkupKeepsTheValueColumnWhereItIs()
+ {
+ // Under EVERYTHING ELSE the *label* is the variable name the server sent, so it is exactly as
+ // hostile as a value — and padding it after escaping is the subtle half: Escape doubles every
+ // bracket, so a name containing `[` padded to NameWidth *characters* is short of NameWidth
+ // *visible cells*, and the value column steps left on that one row.
+ var rows = MsspScreenRenderer.Body(
+ World(),
+ Observed(Report(("PLAIN", ["a"]), ("A[B]C", ["b"]), ("D[[E", ["c"]))),
+ Noon,
+ Width);
+
+ var values = Plain(rows)
+ .Where(l => l.TrimEnd().EndsWith("a", StringComparison.Ordinal)
+ || l.TrimEnd().EndsWith("b", StringComparison.Ordinal)
+ || l.TrimEnd().EndsWith("c", StringComparison.Ordinal))
+ .ToList();
+
+ await Assert.That(values).Count().IsEqualTo(3);
+ await Assert.That(values.Select(l => l.Length).Distinct()).Count().IsEqualTo(1)
+ .Because("a bracket in a variable name must not move the value column");
+ }
+
+ [Test]
+ public async Task AVariableWithAThousandValuesSpendsABoundedNumberOfRows()
+ {
+ var flood = Report(("REFERRAL", Enumerable.Range(0, 1000).Select(i => $"h{i}.example.org 4000").ToArray()));
+ var rows = Plain(MsspScreenRenderer.Body(World(), Observed(flood), Noon, Width));
+
+ await Assert.That(rows.Count(l => l.Contains("example.org"))).IsEqualTo(MsspScreenRenderer.MaxValueRows);
+ await Assert.That(rows.Any(l => l.Contains("more"))).IsTrue();
+ }
+
+ // ---- The shape of the screen ----
+
+ [Test]
+ public async Task EveryStopTheCursorHasIsARowTheScreenDraws()
+ {
+ // Model counts Body's rows and Render draws them, so a cursor stop that was never drawn is
+ // structurally impossible rather than two functions agreeing by inspection.
+ var observation = Observed(Report(("NAME", ["Corvid"]), ("PORT", ["23", "4201"])));
+ var model = MsspScreenRenderer.Model(World(), observation, Noon, Width);
+
+ await Assert.That(model.PaneCount).IsEqualTo(1);
+ await Assert.That(model.Sizes[0])
+ .IsEqualTo(MsspScreenRenderer.Body(World(), observation, Noon, Width).Count);
+ }
+
+ [Test]
+ public async Task TheReportOffersNothingToEditToggleOrRemove()
+ {
+ // Read-only is the shape. Every hint on these screens is derived from the model, so a screen
+ // that offered none of the three physically cannot advertise them.
+ var model = MsspScreenRenderer.Model(World(), Observed(Report(("NAME", ["Corvid"]))), Noon, Width);
+
+ await Assert.That(model.HasEditableRow).IsFalse();
+ await Assert.That(model.HasRemovableRow).IsFalse();
+ await Assert.That(model.HasDetailRow).IsFalse();
+ }
+
+ [Test]
+ public async Task ALongReportScrollsRatherThanLosingItsTail()
+ {
+ var long_ = Report(Enumerable.Range(0, 60)
+ .Select(i => ($"VAR{i}", new[] { $"value {i}" }))
+ .ToArray());
+ var observation = Observed(long_);
+
+ var top = Plain(MsspScreenRenderer.Render(
+ World(), observation, Noon, new ScreenFocus(0, 0), 20, Width));
+ var down = Plain(MsspScreenRenderer.Render(
+ World(), observation, Noon, new ScreenFocus(0, 60), 20, Width));
+
+ await Assert.That(top).Count().IsEqualTo(20);
+ await Assert.That(down).Count().IsEqualTo(20);
+ await Assert.That(string.Join("\n", top)).IsNotEqualTo(string.Join("\n", down));
+
+ // The edges say what they are hiding rather than silently ending.
+ await Assert.That(top[^1]).Contains("more");
+ await Assert.That(down[0]).Contains("more");
+ }
+
+ [Test]
+ public async Task TheReportFitsEveryTerminalItIsRenderedIn()
+ {
+ // Read off the frame the driver was handed, not off the markup: a settings screen is composed
+ // into real controls, and this repository has been bitten by text overrunning a narrow panel.
+ foreach (var (width, height) in new[] { (80, 24), (100, 30), (120, 32), (160, 48) })
+ {
+ foreach (var row in Frame("mssp", width, height))
+ {
+ await Assert.That(row.TrimEnd().Length).IsLessThanOrEqualTo(width)
+ .Because($"the report must fit {width}x{height}");
+ }
+ }
+ }
+}