diff --git a/src/MUI.Crawl/ProbeOptions.cs b/src/MUI.Crawl/ProbeOptions.cs index f37656e..6efd6b3 100644 --- a/src/MUI.Crawl/ProbeOptions.cs +++ b/src/MUI.Crawl/ProbeOptions.cs @@ -123,9 +123,26 @@ public sealed record ProbeOptions /// What the crawler calls itself over TTYPE/MTTS and MNES CLIENT_NAME (spec §11). /// /// + /// /// An admin reading their logs must be able to find out who we are and how to opt out, so this /// is a politeness obligation rather than a cosmetic string. It carries a URL for the same /// reason. + /// + /// + /// It does not reach the wire yet, and neither does . + /// TelnetNegotiationCore's TerminalTypeProtocol hardcodes a client's terminal types to + /// TNC, XTERM, MTTS 3853 in a private field with no setter, and its + /// NewEnvironProtocol answers a server's NEW-ENVIRON request with the crawler host's own + /// USER and a fixed LANG — so what an admin actually sees is the library's default + /// and a local account name, not us. The library is first-party: the fix is a PR there making + /// both settable, never a reflection hack or a hand-rolled plugin here. + /// + /// + /// Until then nothing may claim otherwise. /about reads this field and says plainly that + /// the crawler is configured to call itself this and does not manage to, because + /// "the crawler identifies itself" is a claim about our own behaviour and that one would be + /// false in exactly the way ContactedMaintainer's default was. + /// /// public IReadOnlyList TerminalTypes { get; init; } = ["MUINDEX-CRAWLER", "MUINDEX", "MTTS 9"]; @@ -159,5 +176,11 @@ public sealed record ProbeOptions public const byte MsspOption = 70; /// Where an admin can read what we do and ask us to stop. + /// + /// A placeholder domain, because the domain is an open question (spec §15.1) and inventing one + /// here would settle it by accident. It is also not yet sent to anybody — see + /// — so a deployment that leaves this alone is publishing an address + /// that answers nobody. /about compares against this default and says so when it matches. + /// public string InfoUrl { get; init; } = "https://muindex.example/crawler"; } diff --git a/src/MUI.Web/Components/AboutPage.cs b/src/MUI.Web/Components/AboutPage.cs new file mode 100644 index 0000000..3ca2b85 --- /dev/null +++ b/src/MUI.Web/Components/AboutPage.cs @@ -0,0 +1,364 @@ +using MUI.Crawl; +using MUI.Web.Api; + +namespace MUI.Web.Components; + +/// +/// The about page, as one view model both surfaces render. +/// +/// +/// +/// The page is an obligation rather than a feature. This project reads other people's directories +/// and connects to other people's servers, and spec §7.6 and §11 both say the same thing about that: +/// credit what we read, publish who is knocking and how to make it stop, and state the limits of +/// what a measurement here actually proves. +/// +/// +/// The prose is data because both surfaces have to carry it. A sentence that exists only in the +/// graphical page would be a sentence the plain page could not say, and on this site that is the +/// definition of decoration — see . So the page is a list of sections, the +/// renderers differ in markup and in nothing else, and the parity test reads words rather than tags. +/// +/// +/// Nothing here is written from the design document alone. The spec describes an opt-out over +/// an MSSP field and a DNS TXT record, and a crawler that names itself in TTYPE — none of which is +/// implemented today (see ). Writing the design's intentions here as though +/// they were the deployment's behaviour is the exact shape of the ContactedMaintainer defect +/// this repository already has a record of: a claim about the world compiled in by whoever typed it. +/// +/// +public sealed record AboutPage(string Lede, IReadOnlyList Sections) +{ + /// Every directory credited anywhere on the page, in the order they are shown. + public IReadOnlyList Sources => [.. Sections.SelectMany(s => s.Sources)]; + + /// + /// Builds the page from the two things about it a deployment can change. + /// + /// + /// The crawler's own options, so the identity published here is the object the probe is + /// constructed from rather than a copy of it that can drift. + /// + /// The licence terms this deployment serves its dumps under. + public static AboutPage Build(ProbeOptions probe, DatasetLicenceOptions dataset) => new( + "Every game listed here was measured by a machine that connected to it, and every value " + + "says where it came from and when. This page is what that does and does not amount to: " + + "what we can honestly tell you, what we know we get wrong, whose directories we read to " + + "find the games in the first place, and how to make the crawler stop.", + [ + Measures(), + Limits(), + Never(), + Crawler(probe), + Attribution(), + Licence(dataset), + ]); + + private static AboutSection Measures() => new( + "measures", + "What a fact on this site is", + [ + new("Measured beats declared, and both are shown.", + "A game's MSSP report is the game describing itself. The telnet handshake is us " + + "watching what it actually does. Both appear on a game's page, each labelled with " + + "how it was obtained and how old it is — and where the two disagree, the " + + "disagreement is the interesting fact and is not hidden or averaged away."), + new("A player count comes from one of two places, and says which.", + "Either a WHO or DOING read at the connect screen before logging in, which is a " + + "number we counted, or the game's own MSSP PLAYERS field, which is a number the " + + "game published. They are different claims and are never merged into one figure."), + new("An answer we cannot read is unknown, never zero.", + "MU* servers customise their WHO headers freely and past a point our parser cannot " + + "read one. That produces uncountable, which is its own state. A measured zero — " + + "we got in and nobody was there — is a count, and is printed as the zero it is."), + new("Reachable, never uptime.", + "We open a socket from one host at intervals. That measures whether we could reach " + + "a game from here, and it does not measure whether the game was up: a game with a " + + "routing problem to our vantage point is unreachable and perfectly alive. Nothing " + + "on this site claims to know a game's uptime, because nothing here measured it."), + new("An hour is counted, uncountable, or not measured.", + "The activity grid has three states rather than two, and the third is empty and " + + "names no cause. An hour we could not reach and an hour we never probed look the " + + "same there, because they are the same absence of a measurement — and colouring " + + "either of them as downtime would record a decision of ours as a fact about " + + "somebody else's server."), + ]); + + private static AboutSection Limits() => new( + "limits", + "What we know we get wrong", + [ + new("Archive grace is measured from the day we found you.", + "A game that stops answering leaves the default listing once it has been " + + "unreachable for longer than its grace period, and that period is a quarter of " + + "the reachable time we ourselves probed, floored at 60 days and capped at 365. " + + "So a game running continuously since 1995 starts at the 60-day floor on the day " + + "we discover it and accrues from there. It is a real limitation rather than a " + + "rounding error, and it is the accepted cost of every fact here being measured " + + "here: nothing is imported that would fill in the years before we arrived."), + new("We do not credit MSSP CREATED toward that grace.", + "A game can declare it has existed since 1995 and probably has. It is also one " + + "hand-typed line in a configuration file that nothing verifies, and crediting it " + + "toward the archive threshold would make that threshold gameable by editing that " + + "line. It is shown as the declaration it is, and it buys nothing."), + new("Claiming a game earns the ceiling.", + "Someone who can prove server access has demonstrably staked a claim, and that is " + + "worth the full year of grace regardless of how long we have been watching."), + new("Everything here is one host, looking at intervals.", + "A percentage of reachable time is a fraction of the window we observed and never " + + "of a window we did not. A game we have measured once is a game we have measured " + + "once, and no graphic on this site will imply otherwise by filling in the rest."), + new("Nothing is ever deleted.", + "Archiving takes a game out of the default listing, the rankings and the " + + "active-today figure, and out of nothing else. Its page, its URL, its history and " + + "its address survive, it keeps being probed forever, and one successful probe " + + "puts it straight back."), + ]); + + private static AboutSection Never() => new( + "never", + "What this site will not do", + [ + new("There are no votes, stars, ratings or recommendations.", + "Rankings are computed from measured data and from nothing else. This is not a " + + "feature we have not got round to: a directory ranked by who can mobilise the " + + "most clicks stops describing the hobby and starts describing the campaigning, " + + "and that is what killed the incumbents."), + new("There are no forums, reviews, wikis, comments or player profiles.", + "Orientation material — what a MUSH is, how it differs from a MUD, which codebase " + + "suits collaborative roleplay — is written and signed and versioned like the rest " + + "of the site, rather than opened to editing and then moderated."), + new("Player names are never persisted.", + "A WHO reply is parsed in memory to get a count and the shape of the header, and " + + "the names in it are not written down. Anything aggregated uses a salted hash " + + "with a rotating salt, so an estimate of distinct players is possible while " + + "re-identifying one across salt epochs is not."), + new("No absolute population figure is published.", + "Per-codebase and per-protocol shares ship, because a ratio over the measured set " + + "survives the games we cannot count and the games we have not found. \"How many " + + "people play MU*\" does not ship, because that number would not survive being " + + "quoted."), + ]); + + private static AboutSection Crawler(ProbeOptions probe) => new( + "crawler", + "The crawler, and how to make it stop", + [ + new("A probe is one connection that never logs in.", + "It opens a socket, negotiates telnet options, reads whatever connect screen the " + + "server paints, asks for MSSP by negotiating option 70, sends a single " + + $"{string.Join(" or ", TelnetProbe.PermittedCommands)} at the connect screen, and " + + "disconnects. It creates no character, sends no login, and changes nothing on the " + + "far side. The whole session is bounded by a timeout so a wedged probe cannot sit " + + "on a server's connection slot."), + new("CRAWL DELAY wins.", + "A game that states a preferred minimum gap between crawls in its MSSP report gets " + + "it, and it beats our own schedule in both directions: a game asking for 720 " + + "hours is probed monthly, not weekly. A game that has gone dark is still tried " + + "forever at whichever of the two intervals is longer, which is how a game that " + + "comes back re-lists itself with nobody involved."), + new("A referred address is verified, never trusted.", + "MSSP lets a game name other games. Those names are candidates and not facts: every " + + "one is resolved before anything is dialled and refused unless every address it " + + "resolves to is globally routable, so a hostname pointed at a private or " + + "link-local address reaches nothing. A mixed answer refuses the whole target " + + "rather than picking the good address out of it. A refusal of ours is recorded as " + + "ours and never appears in a game's record as downtime."), + new("Connect screens are shown because they are sent to everybody.", + "A server paints its connect screen, unauthenticated, to every anonymous connection " + + "that arrives. We display it as evidence and label it as what it is. If you would " + + "rather we did not, say so and it comes down — no questions and no argument."), + new("Ask, and we stop.", + "There is no automated opt-out yet. The design calls for one over an MSSP field and " + + "a DNS TXT record; neither is implemented, and advertising a switch that is not " + + "wired to anything would be worse than saying so. Until they exist the route is " + + "to ask a person, and a request is honoured whether or not the machinery for it " + + "is tidy."), + ]) + { + Identity = AboutIdentity.For(probe), + }; + + private static AboutSection Attribution() => new( + "sources", + "Where the list of games came from", + [ + new("We take addresses. Nothing else.", + "A day-one directory needs games to probe, and the existing directories are the " + + "best seed there is. What a backfill takes from them is a host and a port — no " + + "player counts, no reachability history, no descriptions, no fields, and no note " + + "of which site an address was read on."), + new("This is deliberately less than those sites can give.", + "Several of them hold years of dated player counts. Importing that would fill the " + + "heatmaps of exactly the games somebody else was already watching, in a way no " + + "reader could tell from our own measurement without reading the fine print, and " + + "would leave this site's central claim resting on another party's prober."), + new("A game's origin is not one fact, and would be a misleading one.", + "Any game worth listing appears in several of these directories, so \"imported " + + "from\" would name whichever fetch happened to run first rather than anything " + + "about the game. That a game exists is public information published by its " + + "operator to be dialled; where we happened to read it adds nothing a reader can " + + "use, and it is the part of somebody else's work with the least claim to be ours."), + new("Reading somebody's site is still reading somebody's site.", + "Taking less data does not make a crawl less of a crawl. A bulk export or a " + + "documented endpoint is asked for in preference to scraping, robots.txt is read " + + "first, scrapes are rate-limited hard, and a source that needs its maintainer's " + + "say-so is not fetched until a person can state that they were asked."), + ]) + { + Sources = + [ + new("TinTin++ MSSP Mud Crawler", "https://tintin.mudhalla.net/protocols/mssp/", + ImportSourceState.Read, + "One page for one request, published by a crawler that connects to each game and " + + "prints what it read."), + new("TinTin++ MSDP Mud Crawler", "https://tintin.mudhalla.net/protocols/msdp/", + ImportSourceState.Read, + "The same crawler's MSDP listing. Very nearly a subset of its MSSP sibling, read " + + "for the handful of addresses it reaches that the other does not."), + new("The Mud Connector", "https://www.mudconnect.com/", + ImportSourceState.Read, + "Publishes its whole catalogue on one page, so reading all of it costs a single " + + "request. It is the largest contributor of addresses here and contributes no " + + "measurement, which is the split working as intended."), + new("MudStats", "https://mudstats.com/", + ImportSourceState.Read, + "One index page and one page per world, which makes it a scrape rather than an " + + "export. On 30 July 2026 we fetched 143 of their pages — fifteen seconds apart " + + "and honouring their robots.txt, but before anyone had written to them. That " + + "should not have happened. The gate that would have stopped it can no longer be " + + "satisfied by a default in a source file; it now takes a person willing to state " + + "that the maintainer was asked."), + new("MudVerse", "https://www.mudverse.com/", + ImportSourceState.Withheld, + "Implemented, tested, and never run. It is the strongest source in this list on " + + "every axis except permission, and nothing will be fetched from it until somebody " + + "has written to them."), + ], + }; + + private static AboutSection Licence(DatasetLicenceOptions dataset) => new( + "licence", + "Licence", + [ + new("The code is MIT.", + "The site, the crawler and the parsers are open source under the MIT licence."), + new("The licence for the data is an open question.", + "It is a separate decision from the code's and has not been taken. Anyone planning " + + "to build on the dataset should treat the terms below as this deployment's " + + "current answer rather than as the project's settled position — and a rival " + + "directory taking the whole catalogue is a success condition here, not a threat, " + + "so whatever is settled will not be written to stand in the way of one."), + ]) + { + Licence = new AboutLicence( + "MIT", + dataset.LicenceName, + dataset.LicenceUrl, + dataset.Attribution, + dataset.Notice), + }; +} + +/// One headed run of prose, plus whatever structured block belongs under it. +/// +/// The extras are nullable rather than a separate ordered list, so both renderers walk one sequence +/// and neither has an if-ladder deciding where a block goes. A block that appeared in one surface +/// and not the other is the failure this page is a test of. +/// +public sealed record AboutSection(string Id, string Heading, IReadOnlyList Points) +{ + /// Who the crawler says it is, when this section is the one about the crawler. + public AboutIdentity? Identity { get; init; } + + /// The directories credited under this section. + public IReadOnlyList Sources { get; init; } = []; + + /// The terms the data goes out under, when this section is the one about licensing. + public AboutLicence? Licence { get; init; } +} + +/// +/// A lead-in and the paragraph it introduces. +/// +/// +/// Split rather than one string because the graphical page sets the lead in bold and the plain page +/// cannot. Keeping them apart means the emphasis is presentational and the sentence is not — the two +/// surfaces read identically aloud. +/// +public sealed record AboutPoint(string Lead, string Body) +{ + public string Sentence => $"{Lead} {Body}"; +} + +/// +/// What a server administrator sees when we knock, and what to do about it. +/// +/// +/// +/// Read off rather than written out here, so the name published on the +/// page is a property of the object the probe is built from. +/// +/// +/// is false and that is not a formality. Neither +/// nor reaches the wire: +/// TelnetNegotiationCore's client-mode terminal type is a hardcoded private list with no setter, so +/// an administrator reading their logs sees the library's default and not us. The page says so +/// rather than printing a name nobody will ever observe, because "the crawler identifies itself" is +/// a claim about our behaviour and this one would be false. +/// +/// +public sealed record AboutIdentity(string Name, string InfoUrl, bool Announced, bool ContactConfigured) +{ + public static AboutIdentity For(ProbeOptions probe) => new( + probe.TerminalTypes.Count > 0 ? probe.TerminalTypes[0] : "MUINDEX-CRAWLER", + probe.InfoUrl, + // Nothing consumes either field yet. When something does, this becomes a property of the + // probe rather than a constant, and the sentence below changes with it. + Announced: false, + // The built-in value is a placeholder on a domain that has not been chosen. Publishing it as + // the way to reach us, unmarked, would be publishing an address that answers nobody. + ContactConfigured: probe.InfoUrl != new ProbeOptions().InfoUrl); + + /// The honest version of "who is this in my logs", in one sentence. + public string Wording => Announced + ? $"The crawler names itself {Name} when a server asks what it is." + : $"The crawler is configured to call itself {Name}, and does not yet manage to say so: " + + "the telnet library it uses gives a client no way to set the terminal type it reports, so " + + "what reaches your logs is that library's own default, and a NEW-ENVIRON request is " + + "answered from the crawler host's environment rather than with anything about us. Both " + + "are gaps in the library and both are ours to fix there. Until they are fixed, the way to " + + "recognise a probe is its shape: one connection, no login, one WHO, gone."; +} + +/// Whether a directory was actually read, which is not the same as whether we can read it. +public enum ImportSourceState +{ + /// Fetched. Addresses were taken from it. + Read, + + /// + /// Implemented and deliberately not run, because it is a scrape and nobody has asked its + /// maintainer yet. Credited anyway: the reader is owed the whole list, and a source we have + /// chosen not to fetch is a different fact from one we never considered. + /// + Withheld, +} + +/// One directory, credited by name, with what was taken from it and whether it was read. +public sealed record ImportSource(string Name, string Url, ImportSourceState State, string Note) +{ + public string StatusWording => State switch + { + ImportSourceState.Read => "read — addresses only", + _ => "not read — waiting on permission", + }; +} + +/// The two licences, which are two decisions and only one of them has been taken. +public sealed record AboutLicence( + string CodeLicence, + string DataLicenceName, + string? DataLicenceUrl, + string Attribution, + string Notice); diff --git a/src/MUI.Web/Components/Layout/MainLayout.razor b/src/MUI.Web/Components/Layout/MainLayout.razor index 948854c..79592f5 100644 --- a/src/MUI.Web/Components/Layout/MainLayout.razor +++ b/src/MUI.Web/Components/Layout/MainLayout.razor @@ -24,6 +24,7 @@ every game here was checked by a machine, and every fact says when diff --git a/src/MUI.Web/Components/Pages/About.razor b/src/MUI.Web/Components/Pages/About.razor new file mode 100644 index 0000000..c5b74cb --- /dev/null +++ b/src/MUI.Web/Components/Pages/About.razor @@ -0,0 +1,123 @@ +@page "/about" +@using Microsoft.Extensions.Options +@using MUI.Crawl +@using MUI.Web.Api +@inject IOptions Dataset +@inject IServiceProvider Services + +@* + The page this project owes the sites it read and the servers it dials. + + Spec §7.6 and §11 both require it, and neither of them calls it a feature: crediting the + directories a backfill read, publishing who is knocking and how to make them stop, and stating + the limits of what a measurement here proves are obligations already incurred by crawling. + + It is prose and a short list, and it is deliberately the plainest page on the site — the whole + argument this project makes is that a fact stated with its provenance beats a fact presented + well, and the page making that argument would be a poor place to contradict it. +*@ + +@if (Plain) +{ +
@PlainText.RenderAbout(Page)
+} +else +{ + About — mu*index + +

About mu*index

+

@Page.Lede

+ + @foreach (var section in Page.Sections) + { +
+

@section.Heading

+ + @foreach (var point in section.Points) + { +

@point.Lead @point.Body

+ } + + @if (section.Identity is { } identity) + { +

@identity.Wording

+ +
+
crawler
+
@identity.Name
+
contact
+
+ @identity.InfoUrl + @if (!identity.ContactConfigured) + { + @* Amber is the site's word for declared-but-unverified, and an address + nobody configured is exactly that: a value with nothing behind it. *@ + — placeholder; this deployment set no contact address + } +
+
+ } + + @if (section.Sources.Count > 0) + { +
    + @foreach (var source in section.Sources) + { + @* Name and state on one line, the note under both. The listing's two-column + row is wrong here: a note is a paragraph rather than a facts column, and + reserving a column for a short badge leaves the prose reading in a gutter. *@ +
  • +

    + @source.Name + @source.StatusWording +

    +

    @source.Note

    +
  • + } +
+ } + + @if (section.Licence is { } licence) + { +
+
code
+
@licence.CodeLicence
+
data, as this deployment serves it
+
+ @if (licence.DataLicenceUrl is { } url) + { + @licence.DataLicenceName + } + else + { + @licence.DataLicenceName + } +
+
credit as
+
@licence.Attribution
+
+ +

@licence.Notice

+ } +
+ } + +

read this page as plain text

+} + +@code { + [SupplyParameterFromQuery(Name = "plain")] private string? PlainFlag { get; set; } + + private bool Plain => Truthy.Is(PlainFlag); + + /* + The crawler's own options when the crawl loop is hosted in this process, and the defaults + when it is not. Asking the container rather than constructing one means the identity this + page publishes is the identity the probe was built from — a page that said MUINDEX-CRAWLER + while a deployment had configured something else would be describing the source tree instead + of the crawler, which is the failure mode of every claim on this site that nobody pinned. + */ + private AboutPage Page => AboutPage.Build( + Services.GetService(typeof(ProbeOptions)) as ProbeOptions ?? new ProbeOptions(), + Dataset.Value); +} diff --git a/src/MUI.Web/Components/PlainText.cs b/src/MUI.Web/Components/PlainText.cs index 89e054c..aa25301 100644 --- a/src/MUI.Web/Components/PlainText.cs +++ b/src/MUI.Web/Components/PlainText.cs @@ -350,6 +350,79 @@ public static string RenderArchive(IReadOnlyList entries, string? return b.ToString(); } + /// + /// The about page. Prose, so the only thing the graphical version adds is the shape of it. + /// + /// + /// The attribution list is the part that has to survive here above all: it is what this project + /// owes the directories it read, and an acknowledgement a text browser cannot render is an + /// acknowledgement made to the layout rather than to anybody. + /// + public static string RenderAbout(AboutPage page) + { + var b = new StringBuilder(); + + b.AppendLine("ABOUT MU*INDEX"); + b.AppendLine(); + Wrap(b, page.Lede); + + foreach (var section in page.Sections) + { + Heading(b, section.Heading); + + foreach (var point in section.Points) + { + b.AppendLine(); + Wrap(b, point.Sentence, " "); + } + + if (section.Identity is { } identity) + { + b.AppendLine(); + Wrap(b, identity.Wording, " "); + b.AppendLine(); + Wrap(b, $"Crawler: {identity.Name}", " "); + Wrap(b, $"Contact: {identity.InfoUrl}", " "); + + if (!identity.ContactConfigured) + { + Wrap(b, "This deployment has not set a contact address, so the one above is " + + "the built-in placeholder and answers nobody.", " "); + } + } + + foreach (var source in section.Sources) + { + b.AppendLine(); + b.AppendLine($" {source.Name} — {source.StatusWording}"); + b.AppendLine($" {source.Url}"); + Wrap(b, source.Note, " "); + } + + if (section.Licence is { } licence) + { + b.AppendLine(); + // Every one of these goes through the wrapper rather than being laid out in columns: + // a licence name and an attribution are both configuration, and a deployment that + // sets a long one must not push a line off the side of a text browser. + Wrap(b, $"Code: {licence.CodeLicence}", " "); + Wrap(b, $"Data: {licence.DataLicenceName}", " "); + + if (licence.DataLicenceUrl is { } url) + { + Wrap(b, url, " "); + } + + Wrap(b, "(what this deployment serves. The project's own answer is still open.)", " "); + Wrap(b, $"Credit as: {licence.Attribution}", " "); + b.AppendLine(); + Wrap(b, licence.Notice, " "); + } + } + + return b.ToString(); + } + private static void Heading(StringBuilder b, string title) { b.AppendLine(); diff --git a/src/MUI.Web/wwwroot/app.css b/src/MUI.Web/wwwroot/app.css index ceb4a40..940dd43 100644 --- a/src/MUI.Web/wwwroot/app.css +++ b/src/MUI.Web/wwwroot/app.css @@ -517,3 +517,21 @@ fieldset.facets .check { display: inline-flex; gap: 6px; align-items: center; fo border-bottom: 1px solid var(--amber); color: var(--text); } + +/* ── about ───────────────────────────────────────────────────────────────── + Prose measure and nothing else. The one page whose whole job is to say what the rest of the site + can and cannot prove is the wrong place to spend the accent on decoration, so it carries no + colour of its own — the only tinted thing here is a contact address nobody configured, which is + amber for the same reason every other unverified value on this site is. */ + +section.about { max-width: 68ch; } +section.about h2 { margin-top: calc(var(--cpad) * 2.5); } + +dl.identity { font-size: 13px; margin: var(--cpad) 0; } +dl.identity dt { margin-top: 8px; } +dl.identity dd { margin: 2px 0 0; color: var(--dim); } + +ul.sources { list-style: none; margin: var(--cpad) 0 0; padding: 0; } +ul.sources li { border-top: 1px solid var(--line); padding: var(--cpad) 0; } +ul.sources .name { font-size: 15px; font-weight: 600; text-decoration: none; } +ul.sources .source-head { display: flex; gap: var(--cpad); align-items: baseline; flex-wrap: wrap; } diff --git a/tests/MUI.Web.Tests/AboutPageTests.cs b/tests/MUI.Web.Tests/AboutPageTests.cs new file mode 100644 index 0000000..1792177 --- /dev/null +++ b/tests/MUI.Web.Tests/AboutPageTests.cs @@ -0,0 +1,356 @@ +using System.Text.RegularExpressions; + +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +using MUI.Crawl; +using MUI.Web.Api; +using MUI.Web.Components; + +namespace MUI.Web.Tests; + +/// +/// The about page, which is an obligation this project incurred by crawling rather than a feature. +/// +/// +/// +/// The load-bearing test here is not that the page renders: it is that the attribution list names +/// every directory docs/import-sources.md says was read. That file is the record §7.6 leaves +/// behind on main when the importer goes, and a credit that drifts from it is a credit that +/// quietly drops whoever was added last. +/// +/// +/// The rest assert sentences rather than markup, for the reason the whole plain surface exists: a +/// limitation that only survives in the graphical page is a limitation stated to the layout. +/// +/// +public class AboutPageTests +{ + private static AboutPage Page => AboutPage.Build(new ProbeOptions(), new DatasetLicenceOptions()); + + private static string Plain => PlainText.RenderAbout(Page); + + [Test] + public async Task EveryDirectoryTheBackfillReadIsCreditedByNameAndByAddress() + { + // Parsed from the record rather than pasted from it. A source added to the document and not + // to the page is the failure this exists to catch, and a copied list cannot catch it. + var documented = DocumentedSources(); + + await Assert.That(documented).IsNotEmpty(); + await Assert.That(Page.Sources).IsNotEmpty(); + + foreach (var (name, url) in documented) + { + await Assert.That(Plain).Contains(name); + await Assert.That(Plain).Contains(url); + } + } + + [Test] + public async Task ASourceWeChoseNotToFetchIsCreditedAndSaidToBeUnread() + { + // MudVerse is written, tested and deliberately never run. Leaving it off would read as + // completeness; crediting it as read would be the same lie the other way round. + var mudverse = Page.Sources.Single(s => s.Name == "MudVerse"); + + await Assert.That(mudverse.State).IsEqualTo(ImportSourceState.Withheld); + await Assert.That(Plain).Contains("not read — waiting on permission"); + } + + [Test] + public async Task TheAttributionSaysAddressesOnlyAndSaysWhy() + { + await Assert.That(Render.Words(Plain)).Contains("We take addresses. Nothing else."); + await Assert.That(Plain).Contains("no player counts"); + await Assert.That(Plain).Contains("no reachability history"); + } + + [Test] + public async Task TheCrawlOfMudStatsThatWentOutUnaskedIsOnThePage() + { + // The record matters more than the tidy version, and it matters most on the page that asks + // to be trusted about everything else. + var text = Render.Words(Plain); + + await Assert.That(text).Contains("143 of their pages"); + await Assert.That(text).Contains("before anyone had written to them"); + } + + [Test] + public async Task TheArchiveGraceLimitationIsStatedInTheSpecsOwnTerms() + { + var text = Render.Words(Plain); + + await Assert.That(text).Contains("60-day floor on the day we discover it"); + await Assert.That(text).Contains("reachable time we ourselves probed"); + } + + [Test] + public async Task TheReasonMsspCreatedEarnsNoGraceIsGiven() + { + var text = Render.Words(Plain); + + await Assert.That(text).Contains("do not credit MSSP CREATED"); + await Assert.That(text).Contains("gameable"); + } + + [Test] + public async Task TheCrawlerIdentityIsReadOffTheProbeRatherThanWrittenOut() + { + // A name typed into the page is a description of the source tree. This one has to be the + // object the probe is constructed from, or a deployment that configures it gets a page that + // confidently names somebody else. + var probe = new ProbeOptions { TerminalTypes = ["EXAMPLE-CRAWLER"], InfoUrl = "https://example.test/bot" }; + var text = PlainText.RenderAbout(AboutPage.Build(probe, new DatasetLicenceOptions())); + + await Assert.That(text).Contains("EXAMPLE-CRAWLER"); + await Assert.That(text).Contains("https://example.test/bot"); + await Assert.That(text).DoesNotContain("MUINDEX-CRAWLER"); + } + + [Test] + public async Task TheCrawlerIsNotClaimedToAnnounceItselfWhileItCannot() + { + // TelnetNegotiationCore gives a client no way to set its terminal type, so nothing we + // configure reaches an admin's logs. Saying it does would be the ContactedMaintainer defect + // in a new place: a claim about our own behaviour, compiled in by whoever typed it. + var identity = Page.Sections.Single(s => s.Id == "crawler").Identity; + + await Assert.That(identity).IsNotNull(); + await Assert.That(identity!.Announced).IsFalse(); + await Assert.That(Render.Words(Plain)).Contains("configured to call itself"); + } + + [Test] + public async Task AnUnconfiguredContactAddressIsMarkedAsThePlaceholderItIs() + { + // The built-in URL is on a domain nobody has chosen. Printed unmarked it would read as the + // way to reach us, which is the one thing this section exists to provide. + await Assert.That(Plain).Contains("built-in placeholder"); + + var configured = PlainText.RenderAbout(AboutPage.Build( + new ProbeOptions { InfoUrl = "https://example.test/crawler" }, new DatasetLicenceOptions())); + + await Assert.That(configured).DoesNotContain("built-in placeholder"); + } + + [Test] + public async Task TheOptOutIsNotAdvertisedAsAutomatedWhileItIsNot() + { + // Neither the MSSP field nor the DNS TXT record the design describes exists in code. A page + // offering a switch that is wired to nothing is worse than a page admitting there is none. + var text = Render.Words(Plain); + + await Assert.That(text).Contains("no automated opt-out yet"); + await Assert.That(text).Contains("Ask, and we stop."); + } + + [Test] + public async Task ThePoliteAndSecurityFactsAboutAProbeSurvive() + { + var text = Render.Words(Plain); + + await Assert.That(text).Contains("CRAWL DELAY wins."); + await Assert.That(text).Contains("resolved before anything is dialled"); + await Assert.That(text).Contains("globally routable"); + } + + [Test] + public async Task ThePermittedCommandIsReadOffTheProbeAndNotDescribedFromMemory() + { + // "It sends one WHO" is a claim about the probe. The probe publishes the list, so the page + // cannot understate it when a second command is ever added. + foreach (var command in TelnetProbe.PermittedCommands) + { + await Assert.That(Plain).Contains(command); + } + } + + [Test] + public async Task TheMeasuredSpineSurvivesInWords() + { + var text = Render.Words(Plain); + + await Assert.That(text).Contains("Measured beats declared, and both are shown."); + await Assert.That(text).Contains("MSSP PLAYERS field"); + await Assert.That(text).Contains("WHO or DOING read at the connect screen"); + await Assert.That(text).Contains("unknown, never zero"); + } + + [Test] + public async Task ReachableIsExplainedAndNeverCalledUptimeExceptToRefuseTheWord() + { + var text = Render.Words(Plain); + + await Assert.That(text).Contains("Reachable, never uptime."); + + // The word appears twice and both are refusals. Anything else would be the site's own + // vocabulary rule broken on the page that states it. + var uses = Regex.Matches(text, "uptime", RegexOptions.IgnoreCase).Count; + await Assert.That(uses).IsEqualTo(2); + await Assert.That(text).Contains("does not measure whether the game was up"); + await Assert.That(text).Contains("nothing here measured it"); + } + + [Test] + public async Task TheThingsThisSiteWillNotDoAreStatedRatherThanImplied() + { + // The one page where these words are allowed to appear, because it is the page that says + // they are absent. PlainParityTests asserts the opposite about every other surface. + var text = Render.Words(Plain); + + await Assert.That(text).Contains("no votes, stars, ratings or recommendations"); + await Assert.That(text).Contains("no forums, reviews, wikis, comments or player profiles"); + await Assert.That(text).Contains("Player names are never persisted."); + await Assert.That(text).Contains("No absolute population figure is published."); + } + + [Test] + public async Task TheDataLicenceIsPresentedAsUndecidedRatherThanAsSettled() + { + var text = Render.Words(Plain); + + await Assert.That(text).Contains("The code is MIT."); + await Assert.That(text).Contains("licence for the data is an open question"); + await Assert.That(text).Contains("has not been taken"); + + // What the deployment serves is still shown — a consumer needs the terms — but framed as + // this deployment's answer rather than as the project's. + await Assert.That(text).Contains("this deployment serves"); + await Assert.That(text).Contains(new DatasetLicenceOptions().LicenceName); + } + + [Test] + public async Task NoPlainLineIsWiderThanEightyColumns() + { + // The over-long lines rather than the first one, because a width failure is usually a whole + // block that was built without the wrapper and the count is the diagnosis. + var wide = Plain.Split('\n') + .Select(l => l.TrimEnd()) + .Where(l => l.Length > PlainText.Columns) + .ToArray(); + + await Assert.That(wide).IsEmpty(); + } + + [Test] + public async Task TheGraphicalPageCarriesEverySentenceThePlainOneDoes() + { + // The parity that matters: both surfaces render one view model, so nothing can exist on one + // and not the other. Read off the rendered frame, because that is what a browser receives. + var markup = await RenderAboutAsync(); + var text = Text(markup); + + foreach (var section in Page.Sections) + { + await Assert.That(text).Contains(section.Heading); + + foreach (var point in section.Points) + { + await Assert.That(text).Contains(Render.Words(point.Sentence)); + } + + foreach (var source in section.Sources) + { + await Assert.That(text).Contains(source.Name); + await Assert.That(text).Contains(Render.Words(source.Note)); + + // The address is the link here and a printed line there. Asserted against the + // markup rather than the reading text, because a graphical page that spelled its + // URLs out as well would be the plain page with worse typography. + await Assert.That(markup).Contains(source.Url); + } + } + } + + [Test] + public async Task TheGraphicalPageIsReachableWithoutScriptingAndSaysHowToReadItPlainly() + { + var html = await RenderAboutAsync(); + + await Assert.That(html).DoesNotContain(" + /// A rendered page as its reader hears it: tags gone, entities decoded, whitespace collapsed. + /// + /// + /// A sentence whose lead-in is bold is one sentence with a tag inside it, so an assertion that + /// read the markup would be asserting on the emphasis rather than on the claim. + /// + private static string Text(string html) => Render.Words(Regex.Replace(html, "<[^>]+>", " ")); + + /// + /// Renders the page with the services it injects, which cannot supply. + /// + private static async Task RenderAboutAsync() + { + var services = new ServiceCollection(); + services.AddSingleton(NullLoggerFactory.Instance); + services.AddSingleton>( + Options.Create(new DatasetLicenceOptions())); + + await using var provider = services.BuildServiceProvider(); + await using var renderer = new HtmlRenderer(provider, NullLoggerFactory.Instance); + + return await renderer.Dispatcher.InvokeAsync(async () => + { + var output = await renderer.RenderComponentAsync(); + return output.ToHtmlString(); + }); + } + + /// + /// The sources docs/import-sources.md records under ## Read, as name and address. + /// + /// + /// Rows are | [Name](url) | … |; the second table in that stretch is a yield summary with + /// no links in its first cell, so requiring the link is what separates them. + /// + private static IReadOnlyList<(string Name, string Url)> DocumentedSources() + { + var lines = File.ReadAllLines(Path.Combine(RepositoryRoot(), "docs", "import-sources.md")); + var found = new List<(string, string)>(); + var reading = false; + + foreach (var line in lines) + { + if (line.StartsWith("## ", StringComparison.Ordinal)) + { + reading = line.StartsWith("## Read", StringComparison.Ordinal); + continue; + } + + if (!reading) + { + continue; + } + + var match = Regex.Match(line, @"^\|\s*\[([^\]]+)\]\(([^)]+)\)"); + if (match.Success) + { + found.Add((match.Groups[1].Value, match.Groups[2].Value)); + } + } + + return found; + } + + private static string RepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "MUIndex.slnx"))) + { + directory = directory.Parent; + } + + return directory?.FullName + ?? throw new InvalidOperationException("No MUIndex.slnx above " + AppContext.BaseDirectory); + } +}