diff --git a/src/MUI.Catalog/Facets.cs b/src/MUI.Catalog/Facets.cs index f2cf7fc..cc45750 100644 --- a/src/MUI.Catalog/Facets.cs +++ b/src/MUI.Catalog/Facets.cs @@ -41,6 +41,103 @@ public static class FacetKeys public const string Family = "family"; public const string Genre = "genre"; + + /// + /// What order the listing comes back in. A filter parameter by spelling and by plumbing, so the + /// panel, the page and the read API cannot grow two words for one question. + /// + public const string Sort = "sort"; +} + +/// +/// The orders the catalogue can be read in. +/// +/// +/// +/// Every one of these sorts on a fact already on the row. There is no "busiest" here and the +/// word is deliberately not used: /rankings means something specific by it — a median over a +/// window with a sample floor under it — and a sort that reads one instantaneous count would be a +/// cruder question wearing the same name. This is "players on now", which is exactly what it orders +/// by and exactly as much as it claims. +/// +/// +/// There is no "recently listed". The only date we have for that is game.first_seen_at, +/// which is when our crawler first reached a game — a picture of where the frontier has got +/// to, not of anything happening in the hobby (the same reasoning that keeps it off the adoption +/// curves, see EcosystemDashboard). Sorted to the top of the catalogue it would read as "new +/// games", which is a claim we would be making out of our own schedule. The newly discovered +/// feed publishes the same dates with the framing that makes them honest, and that is where it stays. +/// +/// +public enum GameSort +{ + /// + /// Alphabetical, and the default. + /// + /// + /// The default is the one order that ranks nobody. Every other sort in this enum puts some games + /// above others on a measurement, and a listing that arrives pre-ranked is making an editorial + /// claim the reader never asked for — the same objection this project has to star ratings, one + /// step removed. Sorting is a thing a reader chooses. + /// + Name, + + /// Most players counted on right now, first. + Players, + + /// Most recently reached first. + Reached, +} + +/// +/// The listing's order, and what it does with the games a sort cannot rank. +/// +/// +/// +/// An unknown is never a zero and never sorts as one. Most of this catalogue answers with +/// nothing we can count — we got in and the WHO was past our parser, or the game published no +/// PLAYERS — and null ordered as 0 would pile every one of those at the bottom +/// of "players on now" indistinguishably from the games we measured and found empty. That is the +/// central claim of this project made backwards, on the page it is most likely to be read off. +/// +/// +/// So the games a sort can rank come first, in order, and the ones it cannot follow as a group in +/// the default order. is the same question the surfaces ask to know where to +/// draw the line and what to call the group, so the ordering and the label cannot disagree about +/// which games are in it. +/// +/// +public static class GameSorting +{ + /// Whether this sort has nothing to rank a game by — never "whether it is zero". + public static bool IsUnranked(GameSummary game, GameSort sort) + { + ArgumentNullException.ThrowIfNull(game); + + return sort switch + { + GameSort.Players => game.PlayersNow is null, + GameSort.Reached => game.LastReachableAt is null, + _ => false, + }; + } + + public static IReadOnlyList Apply(IEnumerable games, GameSort sort) + { + ArgumentNullException.ThrowIfNull(games); + + // Ranked before unranked, always — then the sort's own key, then the name, so the order is + // total and a listing does not shuffle between two identical requests. + var ordered = games + .OrderBy(g => IsUnranked(g, sort) ? 1 : 0) + .ThenByDescending(g => sort is GameSort.Players ? g.PlayersNow ?? 0 : 0) + .ThenByDescending(g => sort is GameSort.Reached + ? g.LastReachableAt ?? DateTimeOffset.MinValue + : DateTimeOffset.MinValue) + .ThenBy(g => g.Name, StringComparer.OrdinalIgnoreCase); + + return [.. ordered]; + } } /// @@ -377,7 +474,10 @@ public static GameListing Search(IReadOnlyList rows, GameFilter fi groups.AddRange(Presence(results, filter)); - return new GameListing([.. results.Select(r => r.Summary)], groups); + // Ordered after the counting, never before it. Every facet count is taken over a set, and a + // set has no order — so sorting here cannot move a number, which is what lets the panel go on + // promising exactly what a click returns whichever way the list is arranged. + return new GameListing(GameSorting.Apply(results.Select(r => r.Summary), filter.Sort), groups); } /// @@ -656,6 +756,9 @@ public static class FacetTokens public static IReadOnlyList LastSeenBands { get; } = [.. Enum.GetValues().Select(Of)]; + public static IReadOnlyList Sorts { get; } = + [.. Enum.GetValues().Select(Of)]; + /// The three windows that nest, widest last. private static readonly string?[] Nested = [Of(LastSeenBand.Day), Of(LastSeenBand.Week), Of(LastSeenBand.Month)]; @@ -684,10 +787,14 @@ public static class FacetTokens public static string Of(LastSeenBand band) => Camel(band.ToString()); + public static string Of(GameSort sort) => Camel(sort.ToString()); + public static bool TryBand(string? text, out ActivityBand band) => TryRead(text, out band); public static bool TryLastSeen(string? text, out LastSeenBand band) => TryRead(text, out band); + public static bool TrySort(string? text, out GameSort sort) => TryRead(text, out sort); + /// /// Reads one of the derived vocabularies, forgivingly about separators and strictly about /// everything else. diff --git a/src/MUI.Catalog/Views.cs b/src/MUI.Catalog/Views.cs index 47c95b8..a1c2621 100644 --- a/src/MUI.Catalog/Views.cs +++ b/src/MUI.Catalog/Views.cs @@ -197,6 +197,17 @@ public sealed record GameFilter /// /// public FacetChoice? CodebaseFamily { get; init; } + + /// + /// What order the answer comes back in. + /// + /// + /// A question about presentation, and still part of the filter, because the URL is the whole of + /// this page's state: a sorted listing has to be linkable exactly as a filtered one is, and the + /// read API has to answer the same question the page asked. is the + /// default and is the one order that ranks nobody. + /// + public GameSort Sort { get; init; } = GameSort.Name; } /// diff --git a/src/MUI.Web/Api/ApiModels.cs b/src/MUI.Web/Api/ApiModels.cs index 1abfe5e..35629d2 100644 --- a/src/MUI.Web/Api/ApiModels.cs +++ b/src/MUI.Web/Api/ApiModels.cs @@ -195,7 +195,8 @@ public sealed record FilterView( string? Family, string? Genre, string? Language, - string? CodebaseFamily) + string? CodebaseFamily, + GameSort Sort) { public static FilterView Of(GameFilter filter) { @@ -213,7 +214,8 @@ public static FilterView Of(GameFilter filter) filter.Family?.Token, filter.Genre?.Token, filter.Language?.Token, - filter.CodebaseFamily?.Token); + filter.CodebaseFamily?.Token, + filter.Sort); } } diff --git a/src/MUI.Web/Api/GameFilterBinding.cs b/src/MUI.Web/Api/GameFilterBinding.cs index 4803263..69f9b6e 100644 --- a/src/MUI.Web/Api/GameFilterBinding.cs +++ b/src/MUI.Web/Api/GameFilterBinding.cs @@ -63,7 +63,9 @@ private static bool TryRead( { result = null!; - if (!TryBand(read, out var band, out error) || !TryLastSeen(read, out var seen, out error)) + if (!TryBand(read, out var band, out error) + || !TryLastSeen(read, out var seen, out error) + || !TrySort(read, out var sort, out error)) { return false; } @@ -92,6 +94,7 @@ private static bool TryRead( CodebaseFamily = string.IsNullOrWhiteSpace(codebaseFamily) ? null : FacetChoice.Parse(codebaseFamily.Trim()), + Sort = sort, }; result = new GameQuery( @@ -175,6 +178,32 @@ private static bool TryLastSeen(Func read, out LastSeenBan return true; } + /// + /// The listing's order. Refused rather than ignored, like every other unreadable facet: a + /// consumer who asked for ?sort=busiest and silently got the alphabet would read the first + /// name on the page as the busiest game on the site. + /// + private static bool TrySort(Func read, out GameSort sort, out string? error) + { + sort = GameSort.Name; + error = null; + var text = read(FacetKeys.Sort).ToString(); + + if (string.IsNullOrWhiteSpace(text)) + { + return true; + } + + if (!FacetTokens.TrySort(text, out var parsed)) + { + error = $"'{text}' is not a sort order. Accepted: {string.Join(", ", FacetTokens.Sorts)}."; + return false; + } + + sort = parsed; + return true; + } + private static int Bounded(string? value, int fallback, int min, int max) { if (!int.TryParse(value, out var parsed)) diff --git a/src/MUI.Web/Components/ActiveFilters.cs b/src/MUI.Web/Components/ActiveFilters.cs new file mode 100644 index 0000000..15b0d0f --- /dev/null +++ b/src/MUI.Web/Components/ActiveFilters.cs @@ -0,0 +1,134 @@ +using MUI.Catalog; + +namespace MUI.Web.Components; + +/// One thing the query is currently asking for, and the URL that stops asking it. +/// Which question — codebase, search. +/// What it is asking of that question, polarity included. +/// The same listing with this one selection dropped and every other kept. +public sealed record ActiveFilter(string Facet, string Value, string RemoveHref); + +/// +/// Everything the current query asks for, read back out of the facets it was applied to. +/// +/// +/// +/// The panel is not the only place the query is visible: what it is asking for is repeated above the +/// results as a row of chips, each of which is a link that removes itself. That is what makes the +/// third state legible — ?codebase=!Evennia renders as codebase · anything but +/// Evennia, where a <select> scrolled to an option in its second +/// <optgroup> shows nothing at all until you open it — and it is the only affordance on +/// the page that can undo one filter without disturbing the rest. +/// +/// +/// Built from rather than from , deliberately. The +/// facets already carry each value's , computed by the same pass that +/// produced the listing, so a chip cannot claim a selection the query did not apply — and a facet +/// added to the catalogue gets a chip without anything here being told about it. +/// +/// +public static class ActiveFilters +{ + public static IReadOnlyList For( + IReadOnlyList facets, + GameFilter filter, + string? query) + { + ArgumentNullException.ThrowIfNull(facets); + ArgumentNullException.ThrowIfNull(filter); + + var chips = new List(); + + if (!string.IsNullOrWhiteSpace(filter.Text)) + { + chips.Add(new ActiveFilter( + "search", filter.Text.Trim(), Href(ListingLinks.With(query, FacetKeys.Text, null)))); + } + + if (filter.CodebaseFamily is { Exclude: false, Value: { } family }) + { + chips.Add(new ActiveFilter( + "codebase", family, Href(ListingLinks.With(query, FacetKeys.CodebaseFamily, null)))); + } + + var drawn = new HashSet(StringComparer.Ordinal); + + foreach (var group in facets) + { + foreach (var value in group.Values.Where(v => v.State is not FacetState.Unselected)) + { + drawn.Add(group.Key); + + chips.Add(new ActiveFilter( + FacetWords.Group(group.Key), + value.State is FacetState.Excluded + ? FacetWords.Excluded(group.Key, value) + : FacetWords.Value(group.Key, value), + + // A choice facet holds one selection, so removing it drops the parameter; a + // presence facet holds several in one repeatable, comma-separated parameter, so + // removing one has to leave the others behind. + Href(group.Kind is FacetKind.Choice + ? ListingLinks.With(query, group.Key, null) + : ListingLinks.Without(query, group.Key, value.Token)))); + } + } + + // A selection the panel is not offering back. + // + // An open-ended facet's values come from what is in the results, so a selection that matches + // nothing left in them has no value to hang a chip on — ?codebase=!Evennia beside a search + // that returns no Evennia game at all is the ordinary case, not a corner. Left there, the one + // affordance for undoing a filter would go missing exactly when the filter is doing the most + // and the reader can see the least of why. + foreach (var (key, choice) in Open(filter)) + { + if (choice is null || !drawn.Add(key)) + { + continue; + } + + var stand = new FacetValue( + choice.Value ?? FacetChoice.UnknownToken, + Count: 0, + IsSelected: true, + IsUnknown: choice.IsUnknown, + IsExcluded: choice.Exclude); + + chips.Add(new ActiveFilter( + FacetWords.Group(key), + choice.Exclude ? FacetWords.Excluded(key, stand) : FacetWords.Value(key, stand), + Href(ListingLinks.With(query, key, null)))); + } + + // Last, because it widens the answer rather than narrowing it and reads oddly among the + // things that narrow it — but present, because it is a thing the URL is asking for and a + // reader who cannot see it asked has no way to stop asking. + if (filter.IncludeArchived) + { + chips.Add(new ActiveFilter( + "archived", "included", Href(ListingLinks.With(query, FacetKeys.Archived, null)))); + } + + return chips; + } + + /// + /// The facets whose vocabulary comes from the data rather than from an enum, which are the ones + /// that can be asked for a value the current results do not contain. + /// + /// + /// The two derived facets are not here: their values are a fixed vocabulary and a selected one + /// stays in the panel at a count of zero, so it always has a chip already. + /// + private static IEnumerable<(string Key, FacetChoice? Choice)> Open(GameFilter filter) => + [ + (FacetKeys.Charset, filter.Charset), + (FacetKeys.Codebase, filter.Codebase), + (FacetKeys.Family, filter.Family), + (FacetKeys.Genre, filter.Genre), + (FacetKeys.Language, filter.Language), + ]; + + private static string Href(string queryString) => "/games" + queryString; +} diff --git a/src/MUI.Web/Components/FacetPanel.razor b/src/MUI.Web/Components/FacetPanel.razor index b9bff4c..7d96801 100644 --- a/src/MUI.Web/Components/FacetPanel.razor +++ b/src/MUI.Web/Components/FacetPanel.razor @@ -9,105 +9,192 @@ Every count beside a value came from the same pass that produced the listing below it (see FacetedSearch), so a choice cannot promise results it will not deliver, and a value nothing matches is never drawn at all. + + It used to explain itself in a paragraph under the controls, and every facet wore a sentence for + a label — "codebase the game says so". The facts in that prose are all still here; none of them + is prose any more. The measured/declared split is a two-word chip on each group, the counts speak + for themselves, the one reading that has to be prevented at the moment of ticking sits inside the + fieldset it applies to, and the rest is one disclosure a reader can open once and never again. *@ +@* + What the query is asking for, repeated where the results are, one removable chip each. + + Outside the form on purpose: these are links rather than controls, and each undoes exactly one + selection while leaving every other in place — which no scrolled to an option in its + second shows nothing until it is opened. +*@ +@if (Chips.Count > 0) +{ +
+ showing + @foreach (var chip in Chips) + { + + @chip.Facet + @chip.Value + + — stop filtering by this + + } + clear all +
+} + @code { /// /// The same value, spelled as an exclusion — Evennia becomes !Evennia. @@ -124,6 +211,32 @@ [Parameter, EditorRequired] public GameFilter Filter { get; set; } = new(); - private static string Evidence(FacetGroup group) => + /// + /// The querystring this listing is answering, which is what a chip needs to remove itself from. + /// + /// + /// Optional, and an empty one only costs the chips: with no query there is nothing selected, so + /// there is nothing to draw. The panel itself needs no URL — it is a form, and the browser builds + /// the next one out of the controls. + /// + [Parameter] public string Query { get; set; } = string.Empty; + + private IReadOnlyList Chips => ActiveFilters.For(Facets, Filter, Query); + + /// + /// The evidence chip: a glyph, and the word, always both. + /// + /// + /// The glyphs are the site's own state vocabulary — ● for something we watched, ◇ for something + /// only claimed — and they are aria-hidden, because the word beside them is the same fact + /// and a screen reader announcing "black circle measured" is worse than announcing "measured". + /// Colour is never the carrier: the word is legible with no ink at all. + /// + private static RenderFragment Evidence(FacetGroup group) => @ + + @FacetWords.Evidence(group.Evidence) + ; + + private static string Kind(FacetGroup group) => group.Evidence is FacetEvidence.Measured ? "measured" : "declared"; } diff --git a/src/MUI.Web/Components/FacetWords.cs b/src/MUI.Web/Components/FacetWords.cs index c22c7b2..972570b 100644 --- a/src/MUI.Web/Components/FacetWords.cs +++ b/src/MUI.Web/Components/FacetWords.cs @@ -28,7 +28,11 @@ public static class FacetWords FacetKeys.LastSeen => "last seen", FacetKeys.Protocol => "protocols offered", FacetKeys.Tls => "encrypted", - FacetKeys.Charset => "encoding negotiated", + // "encoding", not "encoding negotiated": the evidence chip beside it already says measured, + // and the two words together wrapped the label and knocked its control out of line with the + // rest of the row. What negotiation has to do with it is in the values — "nothing negotiated" + // is what this facet calls a game it has no answer for. + FacetKeys.Charset => "encoding", FacetKeys.Codebase => "codebase", FacetKeys.Family => "family", FacetKeys.Genre => "genre", @@ -37,17 +41,64 @@ public static class FacetWords }; /// - /// How a facet's evidence is described, in three words a reader can act on. + /// What kind of statement a facet is, in one word. /// /// - /// Never abbreviated to a symbol. The difference between something we watched happen and - /// something a game typed into mush.cnf in 2017 is the product, and a legend a reader has + /// + /// A word and never only a symbol. The difference between something we watched happen and + /// something a game typed into mush.cnf in 2017 is the product, and a glyph a reader has /// to learn is a difference they will not read. + /// + /// + /// One word rather than the sentence it used to be. "codebase — the game says so" on every row + /// spends a line of prose per facet saying the same two things over and over, and a panel that + /// explains itself seven times is a panel nobody reads once. The sentence still exists — see + /// — and is said once, beside the two words, where it reads as a + /// key rather than as commentary. + /// /// public static string Evidence(FacetEvidence evidence) => evidence switch { - FacetEvidence.Measured => "we measured this", - _ => "the game says so", + FacetEvidence.Measured => "measured", + _ => "declared", + }; + + /// What each of those two words means, said once per surface rather than once per facet. + public static string EvidenceMeaning(FacetEvidence evidence) => evidence switch + { + FacetEvidence.Measured => "we watched this happen", + _ => "the game says so, and we did not check", + }; + + /// What each sort order is called on the control. + /// + /// Named for the fact each one reads, never for a superlative. "Players on now" is what the + /// column says and what the sort does; "busiest" is /rankings's word for a median over + /// ninety days with a sample floor under it, and lending it to one instantaneous count would be + /// two different measurements answering to one name on the same site. + /// + public static string Sort(GameSort sort) => sort switch + { + GameSort.Players => "players on now", + GameSort.Reached => "last reached", + _ => "name", + }; + + /// + /// What a sort did with the games it had nothing to rank. + /// + /// + /// The sentence that keeps an unknown from reading as a zero. A game we reached and could not + /// count sorts after every counted game rather than among the measured zeroes, and the + /// surface that draws that break has to say what the break is — otherwise the reader sees a list + /// that runs 54, 11, 2, 0, and then a long tail of games showing no number, which is a list that + /// looks exactly like the lie. + /// + public static string Unranked(GameSort sort) => sort switch + { + GameSort.Players => "answering with nothing we can count — not zero players", + GameSort.Reached => "never once reached — not reached long ago", + _ => string.Empty, }; /// One value's label. Open-ended facets are their own labels; the derived ones are not. @@ -69,6 +120,31 @@ public static string Value(string key, FacetValue value) }; } + /// + /// The same value, negated — what the panel's anything but group offers. + /// + /// + /// A closed <select> shows the option and never the group it came from, so each + /// option has to read as a negation on its own. Prefixing "not" does that for a value and makes + /// nonsense of an absence: not not identified is the exclusion of the games whose + /// codebase we could not read, and nobody has ever parsed that phrase on the first try. The + /// absences get the positive sentence they are the absence of. + /// + public static string Excluded(string key, FacetValue value) + { + ArgumentNullException.ThrowIfNull(value); + + return value.IsUnknown ? Known(key) : "not " + Value(key, value); + } + + /// The opposite of — the games this facet has any value for. + private static string Known(string key) => key switch + { + FacetKeys.Charset => "something negotiated", + FacetKeys.Codebase => "identified at all", + _ => "declared at all", + }; + /// /// What "we have no value for this game" is called, per facet. /// diff --git a/src/MUI.Web/Components/ListingLinks.cs b/src/MUI.Web/Components/ListingLinks.cs new file mode 100644 index 0000000..92c6b1c --- /dev/null +++ b/src/MUI.Web/Components/ListingLinks.cs @@ -0,0 +1,90 @@ +using Microsoft.AspNetCore.WebUtilities; + +namespace MUI.Web.Components; + +/// +/// The listing's own URL, rewritten one parameter at a time. +/// +/// +/// +/// The querystring is the whole of this page's state, so every affordance that is not a form control +/// — read this as plain text, surprise me, stop asking for Evennia — is a link to the same URL with +/// one parameter changed. That rewriting is here, once, rather than in each page that needs it: the +/// listing already carried a private copy for the plain-text link, and the moment a second caller +/// wanted "the same query without this facet" the two would have had to agree about repeated +/// parameters, comma-separated values and escaping without ever being compared. +/// +/// +/// Both return a querystring — leading ? or empty — and never a path, because the callers +/// point at three different ones (/games, /games/random, and the page itself). +/// +/// +public static class ListingLinks +{ + /// The same query with one parameter set, replaced, or — on a null value — removed. + public static string With(string? query, string name, string? value) + { + var parts = Pairs(query) + .Where(p => !string.Equals(p.Key, name, StringComparison.OrdinalIgnoreCase)) + .Select(Encode) + .ToList(); + + if (value is not null) + { + parts.Add($"{Uri.EscapeDataString(name)}={Uri.EscapeDataString(value)}"); + } + + return Join(parts); + } + + /// + /// The same query with one facet's selection dropped — the whole parameter, or one value of it. + /// + /// + /// A presence facet is repeatable and comma-separated, because both are what people + /// type (see GameFilterBinding.Protocols), so removing one protocol from + /// ?protocol=GMCP,MSSP has to rewrite the value rather than delete the parameter. Dropping + /// the parameter would silently take MSSP off the query as well, and the chip the reader clicked + /// said nothing about MSSP. + /// + public static string Without(string? query, string name, string? value) + { + if (value is null) + { + return With(query, name, null); + } + + var parts = new List(); + + foreach (var pair in Pairs(query)) + { + if (!string.Equals(pair.Key, name, StringComparison.OrdinalIgnoreCase)) + { + parts.Add(Encode(pair)); + continue; + } + + var kept = pair.Value + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(v => !string.Equals(v, value, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (kept.Count > 0) + { + parts.Add(Encode(new KeyValuePair(pair.Key, string.Join(',', kept)))); + } + } + + return Join(parts); + } + + private static IEnumerable> Pairs(string? query) => + QueryHelpers.ParseQuery(query ?? string.Empty) + .SelectMany(p => p.Value.Select(v => new KeyValuePair(p.Key, v ?? string.Empty))); + + private static string Encode(KeyValuePair pair) => + $"{Uri.EscapeDataString(pair.Key)}={Uri.EscapeDataString(pair.Value)}"; + + private static string Join(List parts) => + parts.Count == 0 ? string.Empty : "?" + string.Join('&', parts); +} diff --git a/src/MUI.Web/Components/Pages/Games.razor b/src/MUI.Web/Components/Pages/Games.razor index aa7096b..6502996 100644 --- a/src/MUI.Web/Components/Pages/Games.razor +++ b/src/MUI.Web/Components/Pages/Games.razor @@ -13,8 +13,10 @@ else { Games — mu*index -

Games

-

Every fact below was measured. Nothing here was typed into a form by us.

+
+

Games

+

Every fact below was measured. Nothing here was typed into a form by us.

+
@if (Error is { } problem) { @@ -28,7 +30,7 @@ else } else { - + @if (Filter.CodebaseFamily is { Exclude: false, Value: { } family }) { @@ -42,37 +44,73 @@ else

} -

- @Listing.Games.Count games · counts measured, never asserted · - show me a random one -

+
+

+ @Listing.Games.Count + @(Listing.Games.Count == 1 ? "game" : "games") +

+

+ @* The order is stated rather than left to be inferred from the first few rows. *@ + by @FacetWords.Sort(Filter.Sort) · counts measured, never asserted + @if (Listing.Games.Count > 0) + { + @* Only where there is something to be random among. An affordance that cannot do + what it offers is worse than one that is not there. *@ + · show me a random one + } +

+
    @foreach (var g in Listing.Games) { -
  • + @* + Where a sort runs out of things it can rank, the list says so before it goes on. + Sorted by players, the counted games end and the ones we reached but could not + count begin — and without this break the page reads 54, 11, 2, 0, and then a long + tail showing no number, which looks exactly like a tail of empty games. It is a + real list item so it survives in the accessibility tree and in plain text. + *@ + @if (BreakBefore(g)) + { +
  • + from here + @FacetWords.Unranked(Filter.Sort) +
  • + } +
  • -
    - @g.Name - @if (g.State is LifecycleState.Archived) - { - archived - } - else if (g.IsClaimed) - { - claimed - } - else - { - unclaimed - } +
    +

    + @g.Name + @* + Archived and claimed are the two states worth a pill. Unclaimed is + the majority state of the catalogue and is drawn as the quiet word + it is — pilled on every row it was chrome, and a badge every game + wears is a badge that distinguishes none of them. The word itself + stays, on every row and in plain text, because "nobody has claimed + this" is a fact about the entry. + *@ + @if (g.State is LifecycleState.Archived) + { + archived + } + else if (g.IsClaimed) + { + claimed + } + else + { + unclaimed + } +

    @if (g.Tagline is { } t) { -
    @t
    +

    @t

    } -
    +

    @if (g.Codebase is { } cb) { @cb @@ -83,24 +121,25 @@ else } @foreach (var p in g.MeasuredProtocols) { - · @p + · @p } -

    +

    -
    +

    @* Three states, spelled rather than implied. A missing count says so in words — it is never rendered as a zero, and never left blank for a - reader to guess at. + reader to guess at. A measured zero keeps the accent glyph every other + count has, because it is every bit as much a measurement. *@ @if (g.PlayersNow is { } n) { - ● @n on + @n on } else { - – count unknown + count unknown } @* @@ -108,17 +147,17 @@ else a reader can check the facet did what it said. Never reached is its own sentence and is never dated from our own first sighting. *@ -

    + @if (g.LastReachableAt is { } seen) { - last reached @Relative.Format(Now - seen) ago + reached @Relative.Ago(Now - seen) } else { never reached } -
    -
    + +

  • } @@ -126,10 +165,16 @@ else @if (Listing.Games.Count == 0) { -

    - Nothing matched. Nothing is ever deleted here, so a name that once worked still does — - try fewer words, drop a filter, or look in the archive. -

    +
    +

    Nothing matched.

    +

    + Nothing is ever deleted here, so a name that once worked still does. Try fewer + words, or drop a filter. +

    +

    + clear every filter · look in the archive +

    +
    } } @@ -160,13 +205,27 @@ else /// link would have quietly dropped it and offered the unfiltered catalogue as "this page, as /// text" — which is the same page in neither sense. /// - private string PlainHref => Relink("plain", "1"); + private string PlainHref => ListingLinks.With(Query, "plain", "1"); /// /// Random, within whatever is on screen (spec §9). The filters ride along, because "surprise me" /// after narrowing to Evennia games means surprise me among those. /// - private string RandomHref => "/games/random" + Relink("plain", null); + private string RandomHref => "/games/random" + ListingLinks.With(Query, "plain", null); + + /// + /// The first game this sort had nothing to rank, which is where the list says so. + /// + /// + /// Held as the game rather than as an index because the loop has the game and not its position, + /// and resolved once per render rather than per row. Null when the sort ranks everything it was + /// given — including when it ranks nothing, which is the case where the break is the first thing + /// on the list and is exactly as worth drawing. + /// + private GameSummary? FirstUnranked => Listing.Games + .FirstOrDefault(g => GameSorting.IsUnranked(g, Filter.Sort)); + + private bool BreakBefore(GameSummary game) => ReferenceEquals(game, FirstUnranked); protected override async Task OnParametersSetAsync() { @@ -184,21 +243,4 @@ else Filter = query.Filter; Listing = await Queries.SearchAsync(Filter); } - - /// This page's querystring with one parameter set, replaced, or — on null — removed. - private string Relink(string name, string? value) - { - var parts = QueryHelpers.ParseQuery(Query) - .Where(p => !string.Equals(p.Key, name, StringComparison.OrdinalIgnoreCase)) - .SelectMany(p => p.Value.Select(v => - $"{Uri.EscapeDataString(p.Key)}={Uri.EscapeDataString(v ?? string.Empty)}")) - .ToList(); - - if (value is not null) - { - parts.Add($"{Uri.EscapeDataString(name)}={Uri.EscapeDataString(value)}"); - } - - return parts.Count == 0 ? string.Empty : "?" + string.Join('&', parts); - } } diff --git a/src/MUI.Web/Components/PlainText.cs b/src/MUI.Web/Components/PlainText.cs index ffc40e5..e5f3143 100644 --- a/src/MUI.Web/Components/PlainText.cs +++ b/src/MUI.Web/Components/PlainText.cs @@ -229,6 +229,12 @@ public static string RenderListing(GameListing listing, GameFilter filter, DateT + (string.IsNullOrWhiteSpace(filter.Text) ? string.Empty : $" matching \"{filter.Text}\"") + (filter.IncludeArchived ? ", archived included" : ", archived excluded")); + // The order, stated. A sorted list that does not say what it is sorted by is one a reader has + // to reverse-engineer from the first few rows — and that is exactly how a tail of games + // showing no number gets read as a tail of games with no players. + b.AppendLine($"Sorted by {FacetWords.Sort(filter.Sort)}" + + $" (?{FacetKeys.Sort}={string.Join('/', FacetTokens.Sorts)})"); + AppendFacets(b, listing.Facets); b.AppendLine(); @@ -239,8 +245,20 @@ public static string RenderListing(GameListing listing, GameFilter filter, DateT return b.ToString(); } + var broken = false; + foreach (var g in games) { + // The same break the rendered listing draws, in the same place and for the same reason: + // where the sort runs out of things it can rank, the list says so rather than letting the + // rows that follow read as the bottom of the ranking. + if (!broken && GameSorting.IsUnranked(g, filter.Sort)) + { + broken = true; + b.AppendLine($"-- from here: {FacetWords.Unranked(filter.Sort)}"); + b.AppendLine(); + } + var mark = g.State is LifecycleState.Archived ? "[archived]" : g.IsClaimed ? "[claimed]" : "[unclaimed]"; b.AppendLine($"{g.Name} {mark}"); b.AppendLine($" /g/{g.Slug}"); @@ -261,7 +279,7 @@ public static string RenderListing(GameListing listing, GameFilter filter, DateT // the oldest bucket, because a game we have never got an answer from has no date and // inventing one from our first sighting would read as its outage. b.AppendLine(g.LastReachableAt is { } seen - ? $" Last reached: {Relative.Format(now - seen)} ago" + ? $" Last reached: {Relative.Ago(now - seen)}" : " Last reached: never — we have not once got an answer from it"); if (g.Tagline is { } tagline) @@ -292,10 +310,16 @@ private static void AppendFacets(StringBuilder b, IReadOnlyList face } Heading(b, "FILTERS"); + Wrap(b, $"Each facet is marked {FacetWords.Evidence(FacetEvidence.Measured)} " + + $"({FacetWords.EvidenceMeaning(FacetEvidence.Measured)}) or " + + $"{FacetWords.Evidence(FacetEvidence.Declared)} " + + $"({FacetWords.EvidenceMeaning(FacetEvidence.Declared)})."); + b.AppendLine(); Wrap(b, "Each count is what choosing that value returns, from the same query as the list " + "below. A protocol is listed when we saw a game offer it, so a game missing from one " + "may simply never have been measured for it and is never a \"no\". Where a facet has " - + "no value for a game it says so in its own words, and that is not a no either."); + + "no value for a game it says so in its own words, and that is not a no either. A " + + "measured zero is a count; an unknown count is not a zero and never sorts as one."); foreach (var group in facets) { diff --git a/src/MUI.Web/Components/Relative.cs b/src/MUI.Web/Components/Relative.cs index c67752d..38f80f3 100644 --- a/src/MUI.Web/Components/Relative.cs +++ b/src/MUI.Web/Components/Relative.cs @@ -10,6 +10,17 @@ namespace MUI.Web.Components; /// public static class Relative { + /// + /// The same age, as something that already happened. + /// + /// + /// 's freshest bucket is the word "now", so a caller appending " ago" to it + /// wrote "last reached now ago" for the ninety seconds after every probe — which, on a listing + /// rendered while a crawl is running, was most of the rows on the page. The suffix belongs to + /// whoever knows whether the bucket came back a duration or a word, and that is here. + /// + public static string Ago(TimeSpan age) => Format(age) is "now" ? "just now" : Format(age) + " ago"; + public static string Format(TimeSpan age) => age switch { { TotalSeconds: < 90 } => "now", diff --git a/src/MUI.Web/wwwroot/app.css b/src/MUI.Web/wwwroot/app.css index dbe7501..bd5aa55 100644 --- a/src/MUI.Web/wwwroot/app.css +++ b/src/MUI.Web/wwwroot/app.css @@ -189,13 +189,6 @@ figure.ansi figcaption { background: var(--surface); } -/* ── listing ───────────────────────────────────────────────────────────── */ - -ul.games { list-style: none; margin: 0; padding: 0; } -ul.games li { border-bottom: 1px solid var(--line); padding: var(--cpad) 0; } -ul.games .name { font-size: 16px; font-weight: 600; text-decoration: none; } -ul.games .meta { font-family: var(--mono); font-size: 12px; color: var(--faint); } - /* Amber means declared, unverified or ageing, and nothing else — so the archived pill is not amber. Archived is a lifecycle state derived from measurement, and colouring it as staleness would put @@ -208,9 +201,6 @@ ul.games .meta { font-family: var(--mono); font-size: 12px; color: var(--faint); } .badge.archived { color: var(--faint); } -fieldset.facets { border: 1px solid var(--line); padding: var(--cpad); margin: 0 0 var(--cpad); } -fieldset.facets legend { font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); } - /* ── things a reader cannot see but a reader still gets ──────────────────── Text present for a screen reader and absent from the picture. Never display:none — that removes it from the accessibility tree as well, which is the opposite of the point. */ @@ -243,7 +233,9 @@ fieldset.facets legend { font: 10px/1 var(--kick); letter-spacing: 0.16em; text- /* ── chrome ────────────────────────────────────────────────────────────── */ -header.site nav { display: flex; gap: var(--cpad); } +/* Wrapping, because seven catalogue links do not fit a 390px phone on one line and the alternative + is a page that scrolls sideways — which the whole layout is otherwise careful never to do. */ +header.site nav { display: flex; gap: var(--cpad); flex-wrap: wrap; } header.site nav a { color: var(--dim); text-decoration: none; } header.site nav a:hover { color: var(--text); text-decoration: underline; text-underline-offset: 3px; } header.site .kicker { margin-left: auto; } @@ -467,26 +459,148 @@ pre.plain-screen { background: var(--recessed); padding: var(--cpad); overflow-x } .ansi-plate p { margin: 0; } -/* ── listing and archive rows ──────────────────────────────────────────── */ +/* ══ listing and archive rows ═══════════════════════════════════════════════ + Two columns and four lines of type. What the game is on the left, what we + last measured of it on the right, and the eye runs down one edge or the other + rather than across every row. + + Density is deliberate and is the reason the row is a grid rather than a stack: + this list is 700 games long and growing, so the name, the count and the age + each have a column of their own and land in the same place on every row. The + hairline is the only separator — the design's own rule is that separation + comes from surface steps, not from rules, and a rule per row at this density + would be the loudest thing on the page. + ═══════════════════════════════════════════════════════════════════════════ */ + +ul.games, ul.archive { list-style: none; margin: 0; padding: 0; } + +ul.games > li, ul.archive > li { + border-bottom: 1px solid var(--line); + padding: var(--row-pad) var(--cpad); + + /* Negative margin so the hover plane runs to the reading measure's own edge and the text does + not shift when it appears — a row that moves under the pointer is a row you mis-click. */ + margin: 0 calc(var(--cpad) * -1); +} + +ul.games > li.game-row:hover { background: var(--surface); } + +.row-main { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 2px var(--cpad); + align-items: baseline; +} + +.row-text { min-width: 0; } +.row-head { margin: 0; display: flex; gap: 8px; align-items: baseline; flex-wrap: wrap; } + +ul.games .name, ul.archive .name { font-size: 15px; font-weight: 600; text-decoration: none; } +ul.games .name:hover { text-decoration: underline; text-underline-offset: 3px; } +ul.archive .name { color: var(--dim); } + +/* The majority state of the catalogue, so it is the quiet word rather than the third pill. A badge + every row wears distinguishes no row from any other, and at 700 rows it is chrome. */ +ul.games .unclaimed { font: 10px/1 var(--kick); letter-spacing: 0.14em; text-transform: uppercase; color: var(--faint); } + +.row-text .tagline { + margin: 2px 0 0; + color: var(--dim); + font-size: 13px; + + /* One line. A game's own description is a paragraph on its page; here it is a hint at what the + game is, and a listing whose rows are different heights cannot be scanned at all. */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +ul.games .meta, ul.archive .meta { margin: 3px 0 0; font-size: 11.5px; color: var(--faint); } +ul.games .meta .protocol { white-space: nowrap; } + +/* The measured column. Tabular figures so the counts line up down the page without a table. */ +.row-figure { + margin: 0; + text-align: right; + font-size: 12px; + white-space: nowrap; + font-variant-numeric: tabular-nums; + display: grid; + gap: 2px; + justify-items: end; +} + +.row-figure .players { color: var(--text); } +.row-figure .players .unit { color: var(--faint); } +.row-figure .players.unknown { color: var(--faint); } +.row-figure .seen { color: var(--faint); font-size: 11.5px; } + +/* Archived: dimmed one step, and that is the whole treatment. No red, no strikethrough, no "dead" — + the entry is a library record for a periodical that ceased publication. */ +ul.games > li.archived { opacity: 0.72; } + +/* + Where a sort ran out of things it could rank. Not a row and not styled as one: it is a rule with a + sentence on it, so the reader can see that the list below it is a different kind of thing from the + list above. The words are the carrier; the rule only says where the change happened. +*/ +li.unranked-break { + display: flex; + gap: 10px; + align-items: baseline; + flex-wrap: wrap; + border-bottom: 1px solid var(--line); + border-top: 1px solid var(--line); + background: var(--surface); + color: var(--dim); + font-size: 12px; + margin-top: var(--cpad); +} -.row-main { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: var(--cpad); align-items: start; } -ul.games .count { font-size: 12px; white-space: nowrap; } +li.unranked-break:hover { background: var(--surface); } -ul.archive { list-style: none; margin: 0; padding: 0; } -ul.archive li { border-bottom: 1px solid var(--line); padding: var(--cpad) 0; } -ul.archive .name { font-size: 16px; font-weight: 600; text-decoration: none; color: var(--dim); } -ul.archive .meta { font-size: 12px; color: var(--faint); margin-top: 4px; } dl.archive-facts { margin: 0; font-size: 12px; min-width: 220px; } dl.archive-facts dd { margin: 0 0 6px; color: var(--dim); } -fieldset.facets { display: flex; gap: var(--cpad); align-items: center; flex-wrap: wrap; } -fieldset.facets .check { display: inline-flex; gap: 6px; align-items: center; font-size: 13px; color: var(--dim); } +/* ── the listing's own header ────────────────────────────────────────────── */ + +.listing-head { margin-bottom: var(--cpad); } +.listing-head p { margin: 0; max-width: 62ch; } + +.listing-bar { + display: flex; + align-items: baseline; + gap: var(--cpad); + flex-wrap: wrap; + margin: calc(var(--cpad) * 1.5) 0 4px; +} + +.listing-bar .result-count { margin: 0; font-size: 13px; } +.listing-bar .result-count strong { font-size: 19px; font-weight: 600; } +.listing-bar .listing-order { margin: 0; } +.listing-bar .listing-order a { color: var(--faint); } + +/* + Nothing matched. A plate rather than a paragraph, because an empty listing is a state and not an + aside — and it says the same thing the archive section says, since a name that once worked here + still resolves. +*/ +.empty-state { + border: 1px dashed var(--line); + border-radius: 8px; + background: var(--recessed); + padding: calc(var(--cpad) * 1.5); + margin: var(--cpad) 0; + max-width: 60ch; +} + +.empty-state .empty-head { font-size: 15px; font-weight: 600; margin: 0 0 4px; } +.empty-state .kicker { margin: 8px 0 0; } /* ── one column below 900px; no horizontal scroll except inside the frame ── */ @media (max-width: 900px) { .two-up, .feeds { grid-template-columns: minmax(0, 1fr); } - .row-main { grid-template-columns: minmax(0, 1fr); } .game-head { flex-direction: column; } header.site { flex-wrap: wrap; } header.site .kicker { margin-left: 0; } @@ -596,50 +710,236 @@ table.ranking tbody th { font-weight: 400; } @media (max-width: 900px) { /* The label stops competing with the bar for a width neither of them has. */ ul.shares li { grid-template-columns: minmax(0, 1fr); gap: 6px; } +} + /* ══ faceted search ═════════════════════════════════════════════════════════ The panel on /games. It is a plain GET form with no script, so everything here dresses controls the browser already knows how to operate — no rule - below is load-bearing for the filter working. + below is load-bearing for the filter working, and every one of them survives + being switched off. + + It reads as one card with three registers stacked in it: the bar a reader + touches on every visit, the facets they touch on some, and a key they read + once. That shape is the point. The panel used to be a column of full-width + label-and-select rows with a paragraph of prose under it — a database form, + and one that explained itself at more length than it took to operate. - Two things are deliberate rather than decorative. The evidence chip beside - each facet's name takes the site's own accent/amber split for measured + Two things here are deliberate rather than decorative. The evidence chip + beside each facet's name takes the site's own accent/amber split for measured versus declared, because a reader has to be able to see which half of the - panel is evidence — and it says the words too, since colour is not a fact. - And a count is never hidden at a narrow width: a facet whose numbers - disappear has stopped saying what a click will produce, which is the only - reason they are there. + panel is evidence — and it says the word too, since colour is not a fact and + the glyph is aria-hidden. And a count is never hidden at any width: a facet + whose numbers disappear has stopped saying what a click will produce, which + is the only reason they are there. ═══════════════════════════════════════════════════════════════════════════ */ -form.facet-form { margin-bottom: var(--gap); } +form.facet-form { + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface); + padding: var(--cpad); + margin: 0 0 var(--cpad); +} + +/* ── the bar ─────────────────────────────────────────────────────────────── */ +/* + The archive's own search box is the same control with a legend on it, so it is styled here rather + than growing a second look for one question asked on two pages. +*/ +.filter-bar, fieldset.facets { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +fieldset.facets { + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface); + padding: var(--cpad); + margin: 0 0 var(--cpad); +} + +fieldset.facets legend { + font: 10px/1 var(--kick); + letter-spacing: 0.16em; + text-transform: uppercase; + color: var(--faint); +} + +.filter-bar input[type="search"], fieldset.facets input[type="search"] { flex: 1 1 16rem; min-width: 0; } + +.bar-field { display: inline-flex; align-items: center; gap: 6px; } +.bar-label { font: 10px/1 var(--kick); letter-spacing: 0.16em; text-transform: uppercase; color: var(--faint); } + +.filter-bar button { flex: none; } + +/* ── one facet ───────────────────────────────────────────────────────────── */ + +/* + auto-fit at a narrow floor, so eight facets pack four to a row on a laptop, two on a tablet and one + on a phone without a breakpoint being written for any of them. +*/ .facet-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(210px, 1fr)); - gap: var(--cpad); + grid-template-columns: repeat(auto-fit, minmax(178px, 1fr)); + gap: 10px var(--cpad); margin-top: var(--cpad); } -.facet { min-width: 0; } -.facet > label, .facet > legend { display: block; font-size: 12px; color: var(--dim); margin-bottom: 4px; } -.facet select { width: 100%; max-width: 100%; padding: 4px 6px; } +/* The control sits at the bottom of its cell, so a label that wraps grows upward and every select in + the row stays on one line. Left to itself the grid aligns the tops, and one long facet name puts + one control half a row below its neighbours. */ +.facet { + min-width: 0; + display: grid; + grid-template-rows: 1fr auto; + align-content: end; +} -fieldset.facet.presence { +.facet > label, .facet > legend { + display: flex; + gap: 6px; + align-items: baseline; + flex-wrap: wrap; + margin-bottom: 3px; + padding: 0; +} + +.facet .facet-name { + font: 10px/1.4 var(--kick); + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--dim); +} + +select { + background: var(--recessed); border: 1px solid var(--line); - border-radius: 4px; - padding: 6px 8px 8px; + color: var(--text); + font: 13px var(--sans); + padding: 5px 7px; + border-radius: 6px; +} + +.facet select { width: 100%; max-width: 100%; } + +/* A facet that is doing something says so at rest, and not only in colour — the chip row above the + results names it in words, and the select's own text is the value it is set to. */ +.facet select.chosen { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); } + +/* ── the presence facet ──────────────────────────────────────────────────── */ + +/* + Real checkboxes in pill-shaped labels. The box stays visible rather than being replaced by a tinted + chip: the tick is what a screen reader reads and what the keyboard operates, and a chip that + carried the state in a background alone would carry it in colour alone. +*/ +fieldset.facet.presence { + border: 0; + padding: 0; + margin: var(--cpad) 0 0; min-width: 0; } -fieldset.facet.presence .check { display: flex; gap: 6px; align-items: baseline; font-size: 13px; } -fieldset.facet.presence .check .count { margin-left: auto; color: var(--dim); font-variant-numeric: tabular-nums; } +fieldset.facet.presence .checks { display: flex; flex-wrap: wrap; gap: 6px; } + +fieldset.facet.presence .check { + display: inline-flex; + gap: 6px; + align-items: center; + font-size: 12.5px; + border: 1px solid var(--line); + border-radius: 99px; + padding: 3px 10px 3px 7px; + background: var(--recessed); + cursor: pointer; +} + +fieldset.facet.presence .check:hover { border-color: var(--dim); } +fieldset.facet.presence .check:has(:checked) { border-color: color-mix(in srgb, var(--accent) 45%, var(--line)); } +fieldset.facet.presence .check .count { color: var(--faint); font-variant-numeric: tabular-nums; } + +/* Where the unticked-is-not-a-no sentence lives: inside the fieldset it is about, at the moment of + ticking, rather than in a key at the bottom of the panel. */ +fieldset.facet.presence .hint { margin: 6px 0 0; font-size: 11.5px; color: var(--faint); max-width: 62ch; } /* Measured and declared, in the site's own two registers. Never the only carrier of the difference — the chip spells it out in words as well, because colour is not a fact. */ -.evidence { font-size: 10px; text-transform: uppercase; letter-spacing: 0.04em; margin-left: 6px; } +.evidence { + font: 10px/1.4 var(--kick); + letter-spacing: 0.1em; + text-transform: uppercase; + white-space: nowrap; +} + .evidence.measured { color: var(--accent); } .evidence.declared { color: var(--amber); } -.facet-note { margin: var(--cpad) 0 0; font-size: 12px; color: var(--dim); max-width: 68ch; } +/* ── the key ─────────────────────────────────────────────────────────────── */ + +/* + What the panel used to say in a paragraph. Three short statements on one line, and one disclosure + holding the readings a reader would otherwise have to guess at. Deliberately not a tooltip and not + a title attribute: this is the difference the whole site exists to publish, and it stays in the + document whether or not a pointer ever hovers anything. +*/ +.facet-key { + display: flex; + flex-wrap: wrap; + gap: 4px 18px; + align-items: baseline; + margin-top: var(--cpad); + padding-top: 10px; + border-top: 1px solid var(--line); + font-size: 11.5px; + color: var(--faint); +} + +.facet-key .key-item { display: inline-flex; gap: 5px; align-items: baseline; } +.facet-key .facet-more { margin-left: auto; } +.facet-key summary { cursor: pointer; color: var(--dim); } + +.facet-key .facet-more[open] { flex: 1 0 100%; margin-left: 0; margin-top: 6px; } +.facet-key .facet-more ul { margin: 6px 0 0; padding-left: 1.2em; max-width: 72ch; } +.facet-key .facet-more li { margin-bottom: 5px; color: var(--dim); } + +/* ── what the query is currently asking for ──────────────────────────────── */ + +/* + One chip per selection, each a link that removes itself. This is the only place the third state is + legible at rest: a sitting on an option in + // its "anything but" group looks like every other select until it is opened, so without + // these the third state is invisible at rest — and a reader who wants to drop one filter has + // to find the control that set it and remember what "any" was called. + const string Url = "?codebase=!Evennia&protocol=GMCP&protocol=MSSP&q=sun"; + + await Assert.That(GameFilterBinding.TryRead(Url, out var query, out _)).IsTrue(); + var listing = await Queries.SearchAsync(query.Filter); + var chips = ActiveFilters.For(listing.Facets, query.Filter, Url); + + await Assert.That(chips.Select(c => c.Value)).Contains("not Evennia"); + await Assert.That(chips.Select(c => c.Value)).Contains("sun"); + + // Removing one value of a repeatable facet leaves the other behind. Dropping the whole + // parameter would take MSSP off the query too, and the chip said nothing about MSSP. + var gmcp = chips.Single(c => c.Value is "GMCP"); + await Assert.That(gmcp.RemoveHref).Contains("MSSP"); + await Assert.That(gmcp.RemoveHref).DoesNotContain("GMCP"); + } + + [Test] + public async Task NothingSelectedIsNoChipsAtAll() + { + var listing = await Queries.SearchAsync(new GameFilter()); + + await Assert.That(ActiveFilters.For(listing.Facets, new GameFilter(), string.Empty)).IsEmpty(); + } + + [Test] + public async Task AnUnreadableSortIsRefusedRatherThanQuietlyIgnored() + { + // The same rule as band and seen. A consumer who asked for ?sort=busiest and silently got + // the alphabet would read the first name on the page as the busiest game on the site. + await Assert.That(GameFilterBinding.TryRead("?sort=busiest", out _, out var error)).IsFalse(); + await Assert.That(error).Contains("players"); + } + + [Test] + public async Task TheSortIsAControlOnThePanelAndAParameterInTheUrl() + { + var html = await PanelAsync(new GameFilter { Sort = GameSort.Players }); + + await Assert.That(html).Contains($"name=\"{FacetKeys.Sort}\""); + + foreach (var sort in Enum.GetValues()) + { + await Assert.That(Render.Words(html)).Contains(FacetWords.Sort(sort)); + } + } + + [Test] + public async Task NoSortCallsItselfBusiestBecauseTheRankingsPageAlreadyMeansSomethingByThat() + { + // /rankings means a median over ninety days with a sample floor under it. A sort over one + // instantaneous count is a cruder question and must not borrow the word — two measurements + // answering to one name on one site is how a reader ends up comparing them. + foreach (var sort in Enum.GetValues()) + { + await Assert.That(FacetWords.Sort(sort)).DoesNotContain("busiest"); + await Assert.That(FacetWords.Sort(sort)).DoesNotContain("popular"); + } + } + private static async Task PanelAsync(GameFilter filter) { var listing = await Queries.SearchAsync(filter); @@ -290,5 +422,6 @@ private static string Describe(GameFilter f) => string.Join( f.Genre?.Token, f.Language?.Token, f.CodebaseFamily, + f.Sort, string.Join(',', f.MeasuredProtocols)); } diff --git a/tests/MUI.Web.Tests/Render.cs b/tests/MUI.Web.Tests/Render.cs index 65b0c9f..62e3949 100644 --- a/tests/MUI.Web.Tests/Render.cs +++ b/tests/MUI.Web.Tests/Render.cs @@ -30,6 +30,22 @@ public static class Render /// reader of the demo site sees. /// public static Task PageAsync(Dictionary parameters) + where TComponent : IComponent => + PageAsync(parameters, string.Empty); + + /// + /// The same page, rendered at a URL. + /// + /// + /// The listing reads its own querystring rather than binding parameter by parameter, because one + /// parser answers both it and the read API — so rendering it at all means giving it somewhere to + /// read that from. Nothing here did until there was a sort whose survival of the URL had to be + /// proved, which is also how "last reached now ago" reached a real page: no test had ever looked + /// at a rendered listing row. + /// + public static Task PageAsync( + Dictionary parameters, + string query) where TComponent : IComponent => ComponentAsync(parameters, services => { @@ -39,8 +55,20 @@ public static Task PageAsync(Dictionary par services.AddSingleton(fixture); services.AddSingleton(TimeProvider.System); services.AddSingleton(new CatalogueSource(IsMeasured: false)); + services.AddSingleton(new StubNavigation(query)); }); + /// A navigation manager with nothing to do but answer "which URL am I on". + private sealed class StubNavigation : NavigationManager + { + public StubNavigation(string query) => + Initialize("http://localhost/", "http://localhost/games" + query); + + protected override void NavigateToCore(string uri, bool forceLoad) + { + } + } + public static async Task ComponentAsync( Dictionary parameters, Action? configure = null) diff --git a/tests/MUI.Web.Tests/SortingTests.cs b/tests/MUI.Web.Tests/SortingTests.cs new file mode 100644 index 0000000..cef26ae --- /dev/null +++ b/tests/MUI.Web.Tests/SortingTests.cs @@ -0,0 +1,149 @@ +using MUI.Catalog; +using MUI.Web.Api; +using MUI.Web.Components; +using MUI.Web.Components.Pages; +using MUI.Web.Fixtures; + +namespace MUI.Web.Tests; + +/// +/// The listing's order, and the one thing it may not do with the games it cannot rank. +/// +/// +/// A large share of this catalogue answers with nothing we can count — we got in and the +/// WHO was past our parser, or the game published no PLAYERS. Sorted as zeroes those +/// games pile up at the bottom of "players on now" indistinguishable from the games we measured and +/// found empty, which is this project's central claim made backwards on the page most likely to be +/// read off. Every test here is about that one sentence. +/// +public class SortingTests +{ + private static readonly FixtureGameQueries Queries = new(); + + [Test] + public async Task AGameWeCouldNotCountSortsAfterEveryGameWeCould() + { + var listing = await Queries.SearchAsync(new GameFilter { Sort = GameSort.Players }); + var counted = listing.Games.Select(g => g.PlayersNow is not null).ToList(); + + // Every counted game before every uncounted one: no true may follow a false. + await Assert.That(counted.SkipWhile(c => c).Any(c => c)).IsFalse(); + await Assert.That(counted).Contains(true); + await Assert.That(counted).Contains(false); + } + + [Test] + public async Task AMeasuredZeroSortsAmongTheCountsAndNotAmongTheUnknowns() + { + // The whole distinction, in one row. We got in and nobody was there, which is a measurement; + // the games below the break are ones we could not count at all. Ranking the zero with them + // would throw away the difference the sort exists to preserve. + var listing = await Queries.SearchAsync(new GameFilter { Sort = GameSort.Players }); + var zero = listing.Games.Single(g => g.PlayersNow is 0); + var firstUnknown = listing.Games.First(g => g.PlayersNow is null); + + await Assert.That(listing.Games.ToList().IndexOf(zero)) + .IsLessThan(listing.Games.ToList().IndexOf(firstUnknown)); + + await Assert.That(GameSorting.IsUnranked(zero, GameSort.Players)).IsFalse(); + } + + [Test] + public async Task AGameWeHaveNeverReachedIsNotAGameWeReachedLongAgo() + { + // The same rule on the other sort. Null has no date, so it cannot be the oldest date — and + // ordering it as one would date our own ignorance as somebody's outage. + var never = Summary("never", players: null, reached: null); + var ancient = Summary("ancient", players: null, reached: FixtureGameQueries.Now.AddYears(-4)); + + var order = GameSorting.Apply([never, ancient], GameSort.Reached); + + await Assert.That(order[0].Name).IsEqualTo("ancient"); + await Assert.That(GameSorting.IsUnranked(never, GameSort.Reached)).IsTrue(); + await Assert.That(GameSorting.IsUnranked(ancient, GameSort.Reached)).IsFalse(); + } + + [Test] + public async Task TheDefaultOrderRanksNobody() + { + // A listing that arrives pre-ranked makes an editorial claim the reader never asked for. + // Sorting is a thing a reader chooses, so the default is the one order that ranks nothing. + await Assert.That(new GameFilter().Sort).IsEqualTo(GameSort.Name); + + GameFilterBinding.TryRead(string.Empty, out var unasked, out _); + await Assert.That(unasked.Filter.Sort).IsEqualTo(GameSort.Name); + + var listing = await Queries.SearchAsync(new GameFilter()); + await Assert.That(listing.Games.Select(g => g.Name)) + .IsEquivalentTo([.. listing.Games.Select(g => g.Name).OrderBy(n => n, StringComparer.OrdinalIgnoreCase)]); + } + + [Test] + public async Task TheListingSaysWhereTheSortRanOutOfThingsToRank() + { + // Without this the page reads 219, 71, 15, 9, 0, and then a long tail of rows showing no + // number — a list that looks exactly like the lie. The break is a real list item, so it is in + // the accessibility tree rather than being a border a sighted reader might notice. + // Unsorted, there is nothing the order failed to rank and so nothing to announce. + await Assert.That(await Render.PageAsync([])).DoesNotContain("unranked-break"); + + var sorted = await Render.PageAsync([], "?sort=players"); + + await Assert.That(sorted).Contains("unranked-break"); + await Assert.That(Render.Words(sorted)).Contains("from here"); + await Assert.That(Render.Words(sorted)).Contains(FacetWords.Unranked(GameSort.Players)); + await Assert.That(FacetWords.Unranked(GameSort.Players)).Contains("not zero"); + } + + [Test] + public async Task ARowNeverPrintsAnAgeAsNowAgo() + { + // Relative.Format's freshest bucket is the word "now", and every caller appending " ago" to + // it wrote "last reached now ago" for the ninety seconds after each probe — which, on a + // listing rendered while a crawl is running, was most of the rows on the page. + var html = Render.Words(await Render.PageAsync([])); + + await Assert.That(html).DoesNotContain("now ago"); + await Assert.That(Relative.Ago(TimeSpan.FromSeconds(10))).IsEqualTo("just now"); + await Assert.That(Relative.Ago(TimeSpan.FromMinutes(20))).IsEqualTo("20m ago"); + } + + [Test] + public async Task ThePlainSurfaceSaysWhatOrderItIsInAndWhereTheBreakFell() + { + // A sorted list that does not say what it is sorted by is one a reader has to + // reverse-engineer from the first few rows, which is how the tail gets misread. If a fact + // only survives graphically, its graphic was decoration. + await Assert.That(GameFilterBinding.TryRead("?sort=players", out var query, out _)).IsTrue(); + + var text = PlainText.RenderListing( + await Queries.SearchAsync(query.Filter), query.Filter, FixtureGameQueries.Now); + + await Assert.That(Render.Words(text)).Contains($"Sorted by {FacetWords.Sort(GameSort.Players)}"); + await Assert.That(Render.Words(text)).Contains(FacetWords.Unranked(GameSort.Players)); + + // And the parameter that changes it, because a text browser cannot operate a