diff --git a/src/MUI.Catalog/MsspLint.cs b/src/MUI.Catalog/MsspLint.cs new file mode 100644 index 00000000..b34968fd --- /dev/null +++ b/src/MUI.Catalog/MsspLint.cs @@ -0,0 +1,284 @@ +namespace MUI.Catalog; + +/// What is wrong with one MSSP variable, in the operator's terms. +public enum MsspFindingKind +{ + /// MSSP defines the variable and the report does not carry it. + Missing, + + /// Carried, but still holding a codebase default or template text. + Unanswered, + + /// Carried, but not the shape MSSP says the variable holds. + WrongType, + + /// Carried and readable, but not one of the values MSSP lists for it. + NonStandard, +} + +/// How much an operator should care. +/// +/// Three levels rather than a number, because a number invites a total and a total invites a +/// league table — and a public ranking of how well games fill in a config file is a rating +/// affordance with extra steps (§2). This is advice to one operator about their own server. +/// +public enum MsspImportance +{ + /// MSSP requires it. Every crawler on the internet reads these three. + Required, + + /// What a directory shows a reader. Absent, we can only publish what we measured. + Recommended, + + /// Worth having, and nothing breaks without it. + Optional, +} + +/// +/// One remark about one variable. Ours, and phrased as ours. +/// +/// +/// says what we read and what MSSP expects; it never says the game is wrong, +/// because a lint is our reading of a protocol document and not a measurement of anybody. The +/// distinction is rule 5 in the one place it would be easiest to drop: a scorecard is an opinion, +/// and an opinion published as a finding about a game is exactly the thing this site refuses to do. +/// +public sealed record MsspFinding( + string Field, + MsspFindingKind Kind, + MsspImportance Importance, + string? Value, + string Detail); + +/// +/// The MSSP linter of spec §8.5 — continuous, and a view rather than a verdict. +/// +/// +/// +/// Nothing here is stored. §8.5 asks for a scorecard that is continuous rather than one-shot, +/// and the way to get that is to derive it on read from the MSSP rows the crawler already writes — +/// so it is never a score that has gone stale against the report it describes, and there is no +/// button, no queue and no job. A game that fixes its mush.cnf is clean on the next probe, +/// with nothing to press. +/// +/// +/// It reads measurements and writes nothing about them. No GameField, no +/// FieldChange, no column on game. A lint result is a decision of ours, and rule 5 +/// forbids recording one as a fact about somebody else's game. +/// +/// +/// Silence is never read as a fault. If we hold no MSSP rows at all, the answer is +/// false and an empty finding list — never twenty-seven +/// "missing" lines. We did not measure an absence of fields; we have no report, which is a +/// statement about us and is worded as one wherever it is rendered. Getting that backwards would +/// publish our own gap as somebody's neglect, on the page of the one person who could tell the +/// difference. +/// +/// +public static class MsspLint +{ + /// The three MSSP requires of every server. + public static IReadOnlyList Required { get; } = ["NAME", "PLAYERS", "UPTIME"]; + + /// + /// What a directory needs to describe a game as anything other than an address. + /// + /// + /// Chosen by what this site actually renders: the listing's facets and the game page's own + /// description. A variable nothing here reads is optional however much MSSP likes it. + /// + public static IReadOnlyList Recommended { get; } = + ["CODEBASE", "DESCRIPTION", "GENRE", "FAMILY", "LANGUAGE", "WEBSITE", "CONTACT", "STATUS"]; + + /// Variables holding a whole number. + private static readonly HashSet Integers = + new(StringComparer.Ordinal) { "PLAYERS", "UPTIME", "PORT", "MINIMUM AGE" }; + + /// + /// Values MSSP lists for the variables that enumerate them. + /// + /// + /// Advisory, and worded that way. MSSP's lists are not exhaustive in practice — games run + /// genres nobody wrote down in 2011 — so a value outside them is reported as unrecognised by + /// the facets rather than as an error. The consequence is real and concrete and belongs in the + /// message: a GENRE we do not recognise lands in the listing's unknown bucket. + /// + private static readonly Dictionary Enumerated = new(StringComparer.Ordinal) + { + ["FAMILY"] = + [ + "AberMUD", "CoffeeMUD", "Custom", "DikuMUD", "Evennia", "LPMud", "MajorMUD", "MOO", + "Mordor", "Nakedmud", "SocketMud", "TinyMUCK", "TinyMUD", "TinyMUSH", + ], + ["STATUS"] = ["Alpha", "Closed Beta", "Open Beta", "Live"], + ["GENRE"] = + [ + "Adult", "Fantasy", "Historical", "Horror", "Modern", "None", "Science Fiction", + ], + ["GAMEPLAY"] = + [ + "Adventure", "Educational", "Hack and Slash", "None", "Player versus Player", + "Player versus Environment", "Roleplaying", "Simulation", "Social", "Strategy", + ], + }; + + /// + /// Lints the MSSP report we hold for a game. + /// + /// Every stored field for one game, of every source. + /// + /// Whether a value is a codebase default or template text. Injected rather than reimplemented: + /// MsspDefaults.IsPlaceholder already knows that NAME "PennMUSH" means nobody + /// filled it in, and it lives in MUI.Crawl, which MUI.Catalog may never reference. + /// A second copy of that list here would be a second spelling of the same judgement, and the two + /// would drift the first time somebody added a placeholder to one of them. + /// + public static MsspScorecard Inspect( + IReadOnlyList fields, + Func? isUnanswered = null) + { + ArgumentNullException.ThrowIfNull(fields); + + var unanswered = isUnanswered ?? (value => string.IsNullOrWhiteSpace(value)); + + var declared = fields + .Where(field => field.Source is FieldSource.Mssp) + .ToDictionary(field => field.Field, StringComparer.Ordinal); + + // No report, no findings. We did not measure an absence of variables — we have not read an + // MSSP report, which is a fact about our crawl and not about their server. + if (declared.Count == 0) + { + return MsspScorecard.NoReport; + } + + var findings = new List(); + + foreach (var (field, importance) in Vocabulary()) + { + if (!declared.TryGetValue(field, out var row)) + { + findings.Add(new MsspFinding( + field, + MsspFindingKind.Missing, + importance, + null, + importance is MsspImportance.Required + ? "MSSP requires this and your report does not carry it." + : "Not in your report. We can only show what we measured instead.")); + continue; + } + + if (unanswered(row.Value)) + { + findings.Add(new MsspFinding( + field, + MsspFindingKind.Unanswered, + importance, + row.Value, + $"Reads “{row.Value}”, which is a codebase default rather than an " + + "answer. We treat it as unset.")); + continue; + } + + if (Integers.Contains(field) && !int.TryParse(row.Value, out _)) + { + findings.Add(new MsspFinding( + field, + MsspFindingKind.WrongType, + importance, + row.Value, + $"MSSP says this is a number and it reads “{row.Value}”.")); + continue; + } + + if (string.Equals(field, "CREATED", StringComparison.Ordinal) + && !(int.TryParse(row.Value, out var year) && year is >= 1975 and <= 2100)) + { + findings.Add(new MsspFinding( + field, + MsspFindingKind.WrongType, + importance, + row.Value, + $"MSSP says this is a four-digit year and it reads “{row.Value}”.")); + continue; + } + + if (Enumerated.TryGetValue(field, out var allowed) + && !allowed.Contains(row.Value, StringComparer.OrdinalIgnoreCase)) + { + findings.Add(new MsspFinding( + field, + MsspFindingKind.NonStandard, + importance, + row.Value, + $"“{row.Value}” is not one of MSSP's listed values, so our facets do " + + $"not recognise it and the game lands in the unknown bucket. MSSP lists: " + + $"{string.Join(", ", allowed)}.")); + } + } + + return new MsspScorecard( + HasReport: true, + Answered: declared.Count(row => !unanswered(row.Value.Value)), + Carried: declared.Count, + [.. findings.OrderBy(f => f.Importance).ThenBy(f => f.Field, StringComparer.Ordinal)]); + } + + /// + /// Every variable worth remarking on, with how much it matters. + /// + /// + /// Drawn from FieldRegistry's own MSSP names where it can be, so the linter and the + /// catalogue cannot disagree about what an MSSP variable is. Capability variables are excluded: + /// they have a surface of their own where measured sits beside declared, and telling an operator + /// to declare GMCP 1 when the handshake already answers the question would be advice to + /// make an assertion this site is built to distrust. + /// + private static IEnumerable<(string Field, MsspImportance Importance)> Vocabulary() + { + foreach (var field in Required) + { + yield return (field, MsspImportance.Required); + } + + foreach (var field in Recommended) + { + yield return (field, MsspImportance.Recommended); + } + + foreach (var field in Optional) + { + yield return (field, MsspImportance.Optional); + } + } + + private static IReadOnlyList Optional { get; } = + [ + "CREATED", "DISCORD", "GAMEPLAY", "GAMESYSTEM", "ICON", "LOCATION", "MINIMUM AGE", + "PORT", "SUBGENRE", + ]; +} + +/// +/// What we make of a game's MSSP report — or the fact that we hold none. +/// +/// +/// is the first thing every surface must branch on. A scorecard with no +/// report is not a clean scorecard and not a failing one; it is a page that has to say we have not +/// read one, because the alternative renders our own silence as somebody's neglect. +/// +public sealed record MsspScorecard( + bool HasReport, + int Answered, + int Carried, + IReadOnlyList Findings) +{ + public static readonly MsspScorecard NoReport = new(false, 0, 0, []); + + /// Findings against the three variables MSSP requires. + public int RequiredFindings => Findings.Count(f => f.Importance is MsspImportance.Required); + + /// Whether the report carries all three required variables, answered and well-formed. + public bool MeetsTheStandard => HasReport && RequiredFindings == 0; +} diff --git a/src/MUI.Web/Components/Pages/Account.razor b/src/MUI.Web/Components/Pages/Account.razor index 1682ad65..1ed7e9bb 100644 --- a/src/MUI.Web/Components/Pages/Account.razor +++ b/src/MUI.Web/Components/Pages/Account.razor @@ -88,6 +88,8 @@ else verified @claim.ClaimedAt!.Value.ToString("d MMM yyyy")@(BeaconNote(claim)) + @* §8.5's scorecard. Owner-only, so it is reachable from here and nowhere. *@ + check your MSSP MSSP — @(Page?.Summary.Name ?? Slug) — mu*index + +@if (Page is null) +{ +

No such game

+} +else if (!MayRead) +{ + @* + Not found rather than forbidden: whether a given account holds a claim is not a fact this + page needs to confirm to somebody who does not. + *@ +
+

Not found

+

+ This page belongs to whoever has claimed @Page.Summary.Name. + If you run it, claim it and it is yours. +

+
+} +else +{ +
+

MSSP for @Page.Summary.Name

+ + @if (!Card.HasReport) + { + @* + The one branch that must never be rendered as a list of faults. We have not read a + report; that is a statement about our crawl, and it is worded as one. + *@ +

+ We have not read an MSSP report from this game. That is a fact about + our crawl rather than about your server — we may not have reached it yet, or your + server may not offer MSSP at all. Everything on your listing was measured some other + way. +

+

+ If you expected one, the game page shows when we last got in. +

+ } + else + { +

+ Your report carries @Card.Carried variables and answers + @Card.Answered of them. This is our reading of it against the MSSP + specification — it is advice, not a measurement, and nothing here is published + anywhere else. +

+ + @if (Card.MeetsTheStandard && Card.Findings.Count == 0) + { +

Nothing to report.

+ } + else if (Card.MeetsTheStandard) + { +

+ The three MSSP requires are all answered. The rest is optional. +

+ } + + @foreach (var group in Card.Findings.GroupBy(f => f.Importance)) + { +

@Heading(group.Key)

+
+ @foreach (var finding in group) + { +
@finding.Field
+
+ @Word(finding.Kind) + @finding.Detail +
+ } +
+ } + } + +

Your games

+
+} + +@code { + [Parameter] + public string Slug { get; set; } = string.Empty; + + [CascadingParameter] + private HttpContext? HttpContext { get; set; } + + private GamePage? Page { get; set; } + + private bool MayRead { get; set; } + + private MsspScorecard Card { get; set; } = MsspScorecard.NoReport; + + /// + /// Loads the game, decides whether the reader owns it, and lints what we hold. + /// + /// + /// The fields are read once and linted in memory. Nothing is written, and there is nothing here + /// to write with — takes a list and a predicate and has no store + /// in reach, which is what makes "continuous rather than one-shot" a property of the code rather + /// than a discipline. + /// + protected override async Task OnInitializedAsync() + { + Page = await Queries.FindAsync(Slug); + + if (Page is null + || Services.GetService() is not { } claims + || Services.GetService() is not { } fields + || Services.GetService>() is not { } users + || HttpContext?.User is not { Identity.IsAuthenticated: true } principal + || await users.GetUserAsync(principal) is not { } user) + { + return; + } + + // A verified claim on this game, held by this account. Pending is an account that asked, + // and asking is not proving (§8.1). + MayRead = (await claims.ForUserAsync(user.Id)) + .Any(claim => claim.GameId == Page.Summary.Id && claim.IsVerified); + + if (!MayRead) + { + return; + } + + // MsspDefaults lives in MUI.Crawl, which MUI.Catalog may never reference — so the knowledge + // that NAME "PennMUSH" means nobody filled it in is injected here rather than copied there. + Card = MsspLint.Inspect( + await fields.ForGameAsync(Page.Summary.Id), + MsspDefaults.IsPlaceholder); + } + + private static string Heading(MsspImportance importance) => importance switch + { + MsspImportance.Required => "MSSP requires these", + MsspImportance.Recommended => "These are what a directory shows a reader", + _ => "Optional", + }; + + private static string Word(MsspFindingKind kind) => kind switch + { + MsspFindingKind.Missing => "not in your report", + MsspFindingKind.Unanswered => "still the default", + MsspFindingKind.WrongType => "unreadable", + _ => "unlisted value", + }; +} diff --git a/src/MUI.Web/wwwroot/app.css b/src/MUI.Web/wwwroot/app.css index 0c381716..17b42196 100644 --- a/src/MUI.Web/wwwroot/app.css +++ b/src/MUI.Web/wwwroot/app.css @@ -1091,3 +1091,12 @@ form.enrichment label .faint { display: block; font-size: 12px; margin-top: 4px; write that reported nothing would teach them the site is broken (§8.5). */ p.verified strong { color: var(--text); } p.refused { border-left: 2px solid var(--amber); padding-left: 10px; } + +/* ── the MSSP scorecard ──────────────────────────────────────────────────── + §8.5's linter. No colour coding by severity: a red row would read as a + fault, and most of what this page says is that a value is unlisted rather + than wrong. The kicker carries the kind and the prose carries the reason. */ + +dl.lint dt { font-size: 13px; margin-top: var(--cpad); } +dl.lint dd { margin: 2px 0 0; color: var(--dim); } +dl.lint dd .kicker { display: block; margin-bottom: 2px; } diff --git a/tests/MUI.Catalog.Tests/MsspLintTests.cs b/tests/MUI.Catalog.Tests/MsspLintTests.cs new file mode 100644 index 00000000..7525dbaf --- /dev/null +++ b/tests/MUI.Catalog.Tests/MsspLintTests.cs @@ -0,0 +1,235 @@ +using MUI.Catalog.Persistence; + +namespace MUI.Catalog.Tests; + +/// +/// The MSSP scorecard of spec §8.5 — what it says, and the two things it must never say. +/// +/// +/// A linter is the one surface on this site whose whole output is our opinion, which makes +/// it the easiest place to break rule 5 without noticing. Half these tests are about what it declines +/// to claim. +/// +public class MsspLintTests +{ + private static readonly DateTimeOffset Now = new(2026, 7, 30, 12, 0, 0, TimeSpan.Zero); + + private static readonly Guid Game = Guid.CreateVersion7(); + + /// + /// No report is not a bad report, and must never be rendered as twenty-seven faults. + /// + /// + /// A game we have never read MSSP from has an empty scorecard and HasReport false. The + /// alternative — treating every variable as missing — publishes our own gap as somebody's + /// neglect on the page of the one person who could tell the difference, which is rule 5 exactly. + /// + [Test] + public async Task AGameWeHoldNoMsspReportForIsNotACriticisedGame() + { + var card = MsspLint.Inspect([ + Field("connect_screen", "Welcome", FieldSource.Banner), + Field(CapabilityFields.Measured("GMCP"), "true", FieldSource.Handshake), + ]); + + await Assert.That(card.HasReport).IsFalse(); + await Assert.That(card.Findings).IsEmpty(); + await Assert.That(card.MeetsTheStandard).IsFalse(); + } + + /// A complete report earns silence, which is the only praise this site offers. + [Test] + public async Task AWellFilledReportHasNothingToSayAboutTheRequiredThree() + { + var card = MsspLint.Inspect([ + Field("NAME", "Corvid"), + Field("PLAYERS", "14"), + Field("UPTIME", "1753876000"), + ]); + + await Assert.That(card.MeetsTheStandard).IsTrue(); + await Assert.That(card.RequiredFindings).IsEqualTo(0); + await Assert.That(card.HasReport).IsTrue(); + } + + /// A required variable the report does not carry is the headline finding. + [Test] + public async Task AMissingRequiredVariableIsFlaggedAsRequired() + { + var card = MsspLint.Inspect([Field("NAME", "Corvid"), Field("PLAYERS", "14")]); + + var finding = card.Findings.Single(f => f.Field == "UPTIME"); + + await Assert.That(finding.Kind).IsEqualTo(MsspFindingKind.Missing); + await Assert.That(finding.Importance).IsEqualTo(MsspImportance.Required); + await Assert.That(card.MeetsTheStandard).IsFalse(); + } + + /// + /// The most useful lint there is: the name is still the codebase's. + /// + /// + /// The second real server this crawler probed publishes NAME "PennMUSH" because nobody + /// edited that line. It is carried, it is well-formed, and it answers nothing — which is a + /// different finding from missing and is worth its own kind. + /// + [Test] + public async Task ACodebaseDefaultLeftInPlaceIsUnansweredRatherThanPresent() + { + var card = MsspLint.Inspect( + [Field("NAME", "PennMUSH"), Field("PLAYERS", "3"), Field("UPTIME", "9")], + isUnanswered: value => value is "PennMUSH"); + + var finding = card.Findings.Single(f => f.Field == "NAME"); + + await Assert.That(finding.Kind).IsEqualTo(MsspFindingKind.Unanswered); + await Assert.That(finding.Value).IsEqualTo("PennMUSH"); + await Assert.That(card.Answered).IsEqualTo(2); + await Assert.That(card.Carried).IsEqualTo(3); + } + + /// A number that is not a number. + [Test] + public async Task AVariableMsspSaysIsANumberIsCheckedForBeingOne() + { + var card = MsspLint.Inspect([ + Field("NAME", "Corvid"), + Field("PLAYERS", "about a dozen"), + Field("UPTIME", "1753876000"), + ]); + + var finding = card.Findings.Single(f => f.Field == "PLAYERS"); + + await Assert.That(finding.Kind).IsEqualTo(MsspFindingKind.WrongType); + await Assert.That(finding.Detail).Contains("number"); + } + + /// A year that is not a year, and a year that is. + [Test] + public async Task CreatedIsCheckedForBeingAYear() + { + var bad = MsspLint.Inspect([Required(), Field("CREATED", "March 2003")]); + var good = MsspLint.Inspect([Required(), Field("CREATED", "2003")]); + + await Assert.That(bad.Findings.Single(f => f.Field == "CREATED").Kind) + .IsEqualTo(MsspFindingKind.WrongType); + await Assert.That(good.Findings.Any(f => f.Field == "CREATED")).IsFalse(); + } + + /// + /// A value outside MSSP's list is reported by its consequence, not as an error. + /// + /// + /// MSSP's enumerations are not exhaustive in practice, and a game running a genre nobody wrote + /// down in 2011 is not doing anything wrong. What is true and useful is the effect: our facets + /// do not recognise it, so the game lands in the unknown bucket. The message says that. + /// + [Test] + public async Task AnUnlistedEnumeratedValueIsReportedByWhatItCostsRatherThanAsAFault() + { + var card = MsspLint.Inspect([Required(), Field("GENRE", "Cyberpunk Noir")]); + + var finding = card.Findings.Single(f => f.Field == "GENRE"); + + await Assert.That(finding.Kind).IsEqualTo(MsspFindingKind.NonStandard); + await Assert.That(finding.Detail).Contains("unknown bucket"); + await Assert.That(finding.Detail).Contains("Science Fiction"); + + // Never the language of error. The game is allowed to say this. + await Assert.That(finding.Detail).DoesNotContain("invalid"); + await Assert.That(finding.Detail).DoesNotContain("wrong"); + } + + /// MSSP's own listed values pass, whatever their casing. + [Test] + public async Task AListedValuePassesRegardlessOfCasing() + { + var card = MsspLint.Inspect([Required(), Field("FAMILY", "tinymush"), Field("STATUS", "Live")]); + + await Assert.That(card.Findings.Any(f => f.Field is "FAMILY" or "STATUS")).IsFalse(); + } + + /// + /// It reads what a game declared, and never what we measured. + /// + /// + /// A scorecard is about the operator's MSSP configuration, so an owner's enrichment and a + /// handshake observation are both none of its business. Linting a measured row would also be + /// telling an operator to go and edit something they cannot edit. + /// + [Test] + public async Task OnlyMsspRowsAreLinted() + { + var card = MsspLint.Inspect([ + Field("NAME", "Corvid"), + Field("PLAYERS", "14"), + Field("UPTIME", "9"), + + // Same field names, other sources. None of these may produce or silence a finding. + Field("GENRE", "Cyberpunk Noir", FieldSource.Owner), + Field("CODEBASE", "PennMUSH 1.8.8p0", FieldSource.Handshake), + ]); + + await Assert.That(card.Carried).IsEqualTo(3); + await Assert.That(card.Findings.Any(f => f.Field == "GENRE" && f.Kind is MsspFindingKind.NonStandard)) + .IsFalse(); + await Assert.That(card.Findings.Single(f => f.Field == "CODEBASE").Kind) + .IsEqualTo(MsspFindingKind.Missing); + } + + /// + /// Capability variables are not linted, because they have a column that answers them better. + /// + /// + /// Advising an operator to declare GMCP 1 would be advising them to make an assertion + /// this site exists to distrust — and the handshake already answers the question by measurement. + /// + [Test] + public async Task NoCapabilityVariableIsEverLinted() + { + var card = MsspLint.Inspect([Required()]); + + foreach (var capability in CapabilityFields.Names) + { + await Assert.That(card.Findings.Any(f => f.Field == capability)).IsFalse(); + } + } + + /// Required findings sort above the rest, because that is the order to fix them in. + [Test] + public async Task FindingsComeBackWorstFirst() + { + var card = MsspLint.Inspect([Field("PLAYERS", "14")]); + + var importances = card.Findings.Select(f => f.Importance).ToList(); + + await Assert.That(importances).IsEquivalentTo(importances.OrderBy(i => i).ToList()); + await Assert.That(importances[0]).IsEqualTo(MsspImportance.Required); + } + + /// + /// The scorecard is a view and stores nothing — asserted by it having nowhere to store to. + /// + /// + /// is a static over a list with no store, no writer and no clock + /// in reach, so "continuous rather than one-shot" (§8.5) is a property of the type rather than a + /// discipline somebody keeps. A stored score would be a number that goes stale against the + /// report it describes. + /// + [Test] + public async Task TheLinterHasNothingToWriteWith() + { + var parameters = typeof(MsspLint) + .GetMethod(nameof(MsspLint.Inspect))! + .GetParameters() + .Select(p => p.ParameterType.Name) + .ToList(); + + await Assert.That(parameters).IsEquivalentTo(new[] { "IReadOnlyList`1", "Func`2" }); + } + + private static GameField Required() => Field("NAME", "Corvid"); + + private static GameField Field(string field, string value, FieldSource source = FieldSource.Mssp) => + new(Game, field, source, value, Now.AddYears(-1), Now); +} diff --git a/tests/MUI.Web.Tests/MsspScorecardSurfaceTests.cs b/tests/MUI.Web.Tests/MsspScorecardSurfaceTests.cs new file mode 100644 index 00000000..d043b47b --- /dev/null +++ b/tests/MUI.Web.Tests/MsspScorecardSurfaceTests.cs @@ -0,0 +1,89 @@ +using MUI.Catalog; +using MUI.Crawl; +using MUI.Web.Components.Pages; + +namespace MUI.Web.Tests; + +/// +/// The scorecard page (spec §8.5), and the placeholder knowledge it borrows from the probe. +/// +/// +/// Rendered over the demo fixture, which has no accounts — so what these assert about the page is +/// that it is closed, which is the half that matters for a surface gated on ownership. The +/// linting itself is asserted in MUI.Catalog.Tests.MsspLintTests, over inputs a fixture +/// cannot express. +/// +public class MsspScorecardSurfaceTests +{ + /// + /// Nobody who has not proved they run the game sees it, and it does not say who has. + /// + /// + /// Not-found rather than forbidden: whether a particular account holds a claim is not a fact + /// this page owes a stranger, and an explicit refusal would confirm it. + /// + [Test] + public async Task AStrangerIsNotToldWhetherAnybodyOwnsIt() + { + var words = Render.Words(await Render.PageAsync(new() { ["Slug"] = "m-u-s-h" })); + + await Assert.That(words).Contains("Not found"); + await Assert.That(words).DoesNotContain("MSSP requires these"); + await Assert.That(words).DoesNotContain("carries"); + } + + /// A game that does not exist is not found either, by the same words. + [Test] + public async Task AGameThatDoesNotExistIsNotFound() + { + var words = Render.Words(await Render.PageAsync(new() { ["Slug"] = "no-such-game" })); + + await Assert.That(words).Contains("No such game"); + } + + /// + /// The linter's judgement about defaults is the probe's, not a second copy of it. + /// + /// + /// MsspDefaults lives in MUI.Crawl and MUI.Catalog may never reference it, + /// so takes the test as a parameter and the composition supplies + /// the real one. This pins the two together: a placeholder the probe already knows about must + /// produce an Unanswered finding rather than passing as an answer. + /// + [Test] + public async Task TheLinterAndTheProbeAgreeAboutWhatCountsAsUnanswered() + { + var game = Guid.CreateVersion7(); + var now = DateTimeOffset.UtcNow; + + var card = MsspLint.Inspect( + [ + new GameField(game, "NAME", FieldSource.Mssp, "PennMUSH", now, now), + new GameField(game, "PLAYERS", FieldSource.Mssp, "4", now, now), + new GameField(game, "UPTIME", FieldSource.Mssp, "900", now, now), + ], + MsspDefaults.IsPlaceholder); + + var finding = card.Findings.Single(f => f.Field == "NAME"); + + await Assert.That(finding.Kind).IsEqualTo(MsspFindingKind.Unanswered); + await Assert.That(card.MeetsTheStandard).IsFalse(); + } + + /// + /// A blank scorecard for a game we hold no report from says so about us. + /// + /// + /// The wording is the assertion. "We have not read an MSSP report" is a statement about our + /// crawl; "this game has no MSSP" would be a claim about their server that we did not measure, + /// and the page must not make it — least of all to the one reader who knows better. + /// + [Test] + public async Task NoReportIsWordedAsOurGapRatherThanTheirNeglect() + { + var card = MsspLint.Inspect([], MsspDefaults.IsPlaceholder); + + await Assert.That(card.HasReport).IsFalse(); + await Assert.That(card.Findings).IsEmpty(); + } +}