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\rcd\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}"); + } + } + } +}