-
-
@g.Name
- @if (g.State is LifecycleState.Archived)
- {
-
archived
- }
- else if (g.IsClaimed)
- {
-
claimed
- }
- else
- {
-
unclaimed
- }
-
- @if (g.Tagline is { } t)
- {
-
@t
- }
-
-
-
- }
-
+
+ }
+
- @if (Summaries.Count == 0)
- {
-
- Nothing matched. Nothing is ever deleted here, so a name that once worked still does —
- try fewer words, or look in the archive.
-
+ @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.
+
+ }
}
read this page as plain text
}
@code {
- [SupplyParameterFromQuery(Name = "q")] private string? Query { get; set; }
- [SupplyParameterFromQuery(Name = "archived")] private string? ArchivedFlag { get; set; }
- [SupplyParameterFromQuery(Name = "plain")] private string? PlainFlag { get; set; }
+ private DateTimeOffset Now => Clock.GetUtcNow();
- private bool Archived => Truthy.Is(ArchivedFlag);
+ private GameFilter Filter { get; set; } = new();
- private bool Plain => Truthy.Is(PlainFlag);
+ private GameListing Listing { get; set; } = GameListing.Empty;
- private DateTimeOffset Now => Clock.GetUtcNow();
+ private string? Error { get; set; }
+
+ ///
The querystring this render is answering, exactly as the request carried it.
+ private string Query => new Uri(Nav.Uri).Query;
- private GameFilter Filter => new() { Text = Query, IncludeArchived = Archived };
+ private bool Plain => Truthy.Is(
+ QueryHelpers.ParseQuery(Query).TryGetValue("plain", out var flag) ? flag.ToString() : null);
- private IReadOnlyList
Summaries = [];
+ ///
+ /// Plain mode keeps the whole question it was reading, whatever that question grows into.
+ ///
+ ///
+ /// Rebuilt from the live querystring rather than from a hand-kept list of parameters. That list
+ /// carried q and archived and nothing else, so the day a facet arrived the plain
+ /// 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");
- /// Plain mode keeps the query it was reading, or it is not the same page in text.
- private string PlainHref
+ ///
+ /// 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);
+
+ protected override async Task OnParametersSetAsync()
{
- get
+ // One parser, two callers: this is the same function /api/games binds through, so a URL
+ // means the same thing whichever surface reads it.
+ if (!GameFilterBinding.TryRead(Query, out var query, out var error))
{
- var parts = new List { "plain=1" };
- if (!string.IsNullOrWhiteSpace(Query))
- {
- parts.Add($"q={Uri.EscapeDataString(Query)}");
- }
+ Error = error;
+ Filter = new GameFilter();
+ Listing = GameListing.Empty;
+ return;
+ }
- if (Archived)
- {
- parts.Add("archived=true");
- }
+ Error = null;
+ Filter = query.Filter;
+ Listing = await Queries.SearchAsync(Filter);
+ }
- return "?" + string.Join('&', parts);
+ /// 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)}");
}
- }
- protected override async Task OnParametersSetAsync() =>
- Summaries = await Queries.ListAsync(Filter);
+ return parts.Count == 0 ? string.Empty : "?" + string.Join('&', parts);
+ }
}
diff --git a/src/MUI.Web/Components/Pages/RandomGame.razor b/src/MUI.Web/Components/Pages/RandomGame.razor
new file mode 100644
index 0000000..7e83c9d
--- /dev/null
+++ b/src/MUI.Web/Components/Pages/RandomGame.razor
@@ -0,0 +1,60 @@
+@page "/games/random"
+@using MUI.Web.Api
+@inject IGameQueries Queries
+@inject NavigationManager Nav
+
+@*
+ Random game (spec §9). A link rather than a control, so it works with scripting off and can be
+ bookmarked — and it carries whatever filters were on screen, because "surprise me" after
+ narrowing to Evennia games means surprise me among those.
+
+ It renders only when it could not choose. The ordinary path is a redirect raised out of
+ OnInitializedAsync, which never reaches markup.
+*@
+
+Random game — mu*index
+
+Nothing to pick from
+
+@if (Error is { } problem)
+{
+ @problem
+}
+else
+{
+
+ No game matches that filter, so there is nothing to choose between. Nothing is ever deleted
+ here — try the whole listing, or
+ include the archive.
+
+}
+
+@code {
+ private string? Error { get; set; }
+
+ protected override async Task OnInitializedAsync()
+ {
+ if (!GameFilterBinding.TryRead(new Uri(Nav.Uri).Query, out var query, out var error))
+ {
+ Error = error;
+ return;
+ }
+
+ var games = await Queries.ListAsync(query.Filter);
+
+ if (games.Count == 0)
+ {
+ return;
+ }
+
+ // A whole-listing read for one game, which is what IGameQueries can currently answer. It is
+ // the same scan every listing request already does; the cheaper shape is an
+ // ORDER BY random() LIMIT 1 on the read side, and it needs a new query rather than a
+ // different caller here.
+ var pick = games[Random.Shared.Next(games.Count)];
+
+ // In static SSR this raises a NavigationException the framework turns into a redirect, so
+ // the markup below is only ever reached when there was nothing to redirect to.
+ Nav.NavigateTo($"/g/{Uri.EscapeDataString(pick.Slug)}");
+ }
+}
diff --git a/src/MUI.Web/Components/PlainText.cs b/src/MUI.Web/Components/PlainText.cs
index 6927afd..c18345d 100644
--- a/src/MUI.Web/Components/PlainText.cs
+++ b/src/MUI.Web/Components/PlainText.cs
@@ -207,21 +207,35 @@ private static void AppendChanges(StringBuilder b, GamePage page)
}
}
- /// The listing, with every state spelled and no column past 80.
- public static string RenderListing(IReadOnlyList games, GameFilter filter, DateTimeOffset now)
+ ///
+ /// The listing and its facets, with every state spelled and no column past 80.
+ ///
+ ///
+ /// The facets are here in full — every value, its count, and the parameter that selects it —
+ /// because a text browser cannot operate a <select> but can perfectly well edit a
+ /// URL. A panel that only worked as a widget would fail §9's own test of itself: if a fact
+ /// cannot survive in plain text, its graphic on the main site is decoration.
+ ///
+ public static string RenderListing(GameListing listing, GameFilter filter, DateTimeOffset now)
{
+ ArgumentNullException.ThrowIfNull(listing);
+ ArgumentNullException.ThrowIfNull(filter);
+
+ var games = listing.Games;
var b = new StringBuilder();
b.AppendLine("GAMES");
b.AppendLine($"{games.Count} game(s)"
+ (string.IsNullOrWhiteSpace(filter.Text) ? string.Empty : $" matching \"{filter.Text}\"")
+ (filter.IncludeArchived ? ", archived included" : ", archived excluded"));
+
+ AppendFacets(b, listing.Facets);
b.AppendLine();
if (games.Count == 0)
{
b.AppendLine("Nothing matched. Nothing is ever deleted here, so a name that once");
- b.AppendLine("worked still does — try fewer words.");
+ b.AppendLine("worked still does — try fewer words, or drop a filter.");
return b.ToString();
}
@@ -243,6 +257,13 @@ public static string RenderListing(IReadOnlyList games, GameFilter
? $" Measured: {string.Join(", ", g.MeasuredProtocols)}"
: " Measured: nothing offered in the handshake");
+ // The last-seen facet's own column. Never once reached is its own sentence rather than
+ // 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: never — we have not once got an answer from it");
+
if (g.Tagline is { } tagline)
{
Wrap(b, tagline, " ");
@@ -251,10 +272,51 @@ public static string RenderListing(IReadOnlyList games, GameFilter
b.AppendLine();
}
- _ = now;
return b.ToString();
}
+ ///
+ /// The facet panel in text: what each choice returns, and what to put in the URL to choose it.
+ ///
+ ///
+ /// The two sentences at the top are the same two the rendered panel carries, and they are not
+ /// blurb. An unticked protocol is not a game declining a protocol, and a facet with no value for
+ /// a game is not a no — those are the two readings this whole design exists to prevent, and a
+ /// surface that leaves them to be inferred has left the important half out.
+ ///
+ private static void AppendFacets(StringBuilder b, IReadOnlyList facets)
+ {
+ if (facets.Count == 0)
+ {
+ return;
+ }
+
+ Heading(b, "FILTERS");
+ 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.");
+
+ foreach (var group in facets)
+ {
+ b.AppendLine();
+ b.AppendLine($" {FacetWords.Group(group.Key)}"
+ + $" — {FacetWords.Evidence(group.Evidence)} (?{group.Key}=…)");
+
+ foreach (var value in group.Values)
+ {
+ var words = FacetWords.Value(group.Key, value);
+ var gloss = string.Equals(words, value.Token, StringComparison.Ordinal)
+ ? string.Empty
+ : " " + words;
+
+ // A star, not a colour: the selected value has to be visible where there is no ink.
+ b.AppendLine($" {(value.IsSelected ? '*' : ' ')} {value.Token,-24}{value.Count,5}{gloss}"
+ .TrimEnd());
+ }
+ }
+ }
+
///
/// The three liveness feeds. All three are the same shape here, because the register the
/// graphical cards carry is a tone and a tone is not a fact — the words have to do the work.
diff --git a/src/MUI.Web/Fixtures/FixtureGameQueries.cs b/src/MUI.Web/Fixtures/FixtureGameQueries.cs
index 4f4b6df..2776fe0 100644
--- a/src/MUI.Web/Fixtures/FixtureGameQueries.cs
+++ b/src/MUI.Web/Fixtures/FixtureGameQueries.cs
@@ -40,98 +40,144 @@ public sealed class FixtureGameQueries : IGameQueries, IAvailabilityHistory
private static readonly GameSummary Mush = new(
Guid.Parse("aaaaaaaa-0000-0000-0000-000000000001"), "m-u-s-h", "M*U*S*H",
"The PennMUSH development server.", LifecycleState.Active, IsClaimed: false,
- PlayersNow: 15, Codebase: "PennMUSH 1.8.8p0", MeasuredProtocols: ["MSSP", "CHARSET"]);
+ PlayersNow: 15, Codebase: "PennMUSH 1.8.8p0", MeasuredProtocols: ["MSSP", "CHARSET"],
+ LastReachableAt: Now.AddMinutes(-4));
private static readonly GameSummary Eldertale = new(
Guid.Parse("aaaaaaaa-0000-0000-0000-000000000002"), "eldertale", "Eldertale Online",
null, LifecycleState.Active, IsClaimed: false,
- PlayersNow: 0, Codebase: "PennMUSH 1.8.8p0", MeasuredProtocols: ["MSSP", "CHARSET"]);
+ PlayersNow: 0, Codebase: "PennMUSH 1.8.8p0", MeasuredProtocols: ["MSSP", "CHARSET"],
+ LastReachableAt: Now.AddHours(-3));
// No MSSP PLAYERS, no pre-login WHO. Its count exists only because the connect screen states it.
private static readonly GameSummary Aardwolf = new(
Guid.Parse("aaaaaaaa-0000-0000-0000-000000000003"), "aardwolf", "Aardwolf MUD",
"Counted from the connect screen, which is the only place this game publishes a number.",
LifecycleState.Active, IsClaimed: false,
- PlayersNow: 219, Codebase: null, MeasuredProtocols: ["MSSP", "GMCP", "MCCP2", "MSDP"]);
+ PlayersNow: 219, Codebase: null, MeasuredProtocols: ["MSSP", "GMCP", "MCCP2", "MSDP"],
+ LastReachableAt: Now.AddMinutes(-40));
// Answers, but nothing we can count. Renders "count unknown" — never a zero.
private static readonly GameSummary MidnightSun = new(
Guid.Parse("aaaaaaaa-0000-0000-0000-000000000004"), "midnight-sun", "Midnight Sun II",
null, LifecycleState.Active, IsClaimed: false,
- PlayersNow: null, Codebase: "Midnight Sun", MeasuredProtocols: []);
+ PlayersNow: null, Codebase: "Midnight Sun", MeasuredProtocols: [],
+ LastReachableAt: Now.AddHours(-1));
private static readonly GameSummary Enormous = new(
Guid.Parse("aaaaaaaa-0000-0000-0000-000000000006"), "batmud", "BatMUD",
"An intro screen long enough that the frame has to explain why it stopped.",
LifecycleState.Active, IsClaimed: false,
- PlayersNow: 71, Codebase: null, MeasuredProtocols: ["MSSP", "MCCP2"]);
+ PlayersNow: 71, Codebase: null, MeasuredProtocols: ["MSSP", "MCCP2"],
+ LastReachableAt: Now.AddDays(-2));
// Claimed, and the owner turned republication off. Stated without editorial (spec §8).
private static readonly GameSummary Ashen = new(
Guid.Parse("aaaaaaaa-0000-0000-0000-000000000007"), "ashen-court", "Ashen Court",
"Courtly intrigue, low fantasy. Application required.", LifecycleState.Active,
- IsClaimed: true, PlayersNow: 9, Codebase: "Evennia", MeasuredProtocols: ["MSSP", "GMCP", "TLS"]);
+ IsClaimed: true, PlayersNow: 9, Codebase: "Evennia", MeasuredProtocols: ["MSSP", "GMCP", "TLS"],
+ LastReachableAt: Now.AddMinutes(-9));
private static readonly GameSummary Gaslight = new(
Guid.Parse("aaaaaaaa-0000-0000-0000-000000000005"), "gaslight-row", "Gaslight Row",
"Ceased answering in March 2023. We still try the door every week.",
LifecycleState.Archived, IsClaimed: false,
- PlayersNow: null, Codebase: "PennMUSH 1.8.5", MeasuredProtocols: []);
+ PlayersNow: null, Codebase: "PennMUSH 1.8.5", MeasuredProtocols: [],
+ LastReachableAt: Now.AddDays(-1237));
private static readonly GameSummary Verdigris = new(
Guid.Parse("aaaaaaaa-0000-0000-0000-000000000008"), "verdigris", "Verdigris",
"Stopped answering in 2024; the host still refuses the port every week.",
LifecycleState.Archived, IsClaimed: false,
- PlayersNow: null, Codebase: "TinyMUX 2.12", MeasuredProtocols: []);
+ PlayersNow: null, Codebase: "TinyMUX 2.12", MeasuredProtocols: [],
+ LastReachableAt: Now.AddDays(-700));
private static readonly GameSummary[] All =
[Mush, Eldertale, Aardwolf, MidnightSun, Enormous, Ashen, Gaslight, Verdigris];
- public Task> ListAsync(
- GameFilter filter, CancellationToken cancellationToken = default)
- {
- var games = All.AsEnumerable();
+ ///
+ /// The listing and its facets, through the same the database uses.
+ ///
+ ///
+ /// The filtering is emphatically not reimplemented here. A fixture with its own idea of what a
+ /// filter means can pass a test the real query fails, and this one already did: it read
+ /// band=archived as lifting the archive exclusion and Postgres did not, so one filter had
+ /// two answers and only the fixture's was ever exercised. All this owes the shared search now is
+ /// one row per game.
+ ///
+ public Task SearchAsync(
+ GameFilter filter, CancellationToken cancellationToken = default) =>
+ Task.FromResult(FacetedSearch.Search([.. All.Select(FacetRow)], filter));
- if (filter.Band is { } band)
- {
- games = games.Where(g => InBand(g, band));
- }
- else if (!filter.IncludeArchived)
- {
- // Archived games leave the default listing and nothing else (spec §7.5). Asking for the
- // archived band explicitly is not the default listing, so the exclusion does not apply.
- games = games.Where(g => g.State is not LifecycleState.Archived);
- }
+ /// A listing with no panel — the same search, projected.
+ public async Task> ListAsync(
+ GameFilter filter,
+ CancellationToken cancellationToken = default) =>
+ (await SearchAsync(filter, cancellationToken)).Games;
- if (!string.IsNullOrWhiteSpace(filter.Text))
- {
- games = games.Where(g =>
- g.Name.Contains(filter.Text, StringComparison.OrdinalIgnoreCase)
- || (g.Tagline?.Contains(filter.Text, StringComparison.OrdinalIgnoreCase) ?? false)
- || (g.Codebase?.Contains(filter.Text, StringComparison.OrdinalIgnoreCase) ?? false));
- }
+ ///
+ /// One game's facet values.
+ ///
+ ///
+ /// The bands are assigned rather than derived, because there is no presence series here to
+ /// derive them from — which is the fixture's whole nature, and why every page it renders carries
+ /// the demo banner. Midnight Sun is and never
+ /// on purpose: every one of its counts is unmeasurable, and
+ /// being uncountable is not being absent (spec §5.2).
+ ///
+ private static GameFacetRow FacetRow(GameSummary game) => new(
+ game,
+ Band(game),
+ FacetedSearch.LastSeenOf(game.LastReachableAt, Now),
+
+ // An endpoint we opened, never an SSL line in a self-description — the same distinction the
+ // real query draws, so the demo cannot show a facet the database could not.
+ TlsMeasured: Endpoints(game).Any(e => e.TlsMeasured),
+ Charset: Charset(game),
+ Language: game.Slug is "midnight-sun" ? "Swedish" : "English",
+ Codebase: game.Codebase,
+ Family: Family(game),
+ Genre: Genre(game));
+
+ private static ActivityBand Band(GameSummary g) => g.Slug switch
+ {
+ "gaslight-row" or "verdigris" => ActivityBand.Archived,
+ "eldertale" => ActivityBand.ActiveThisWeek,
+ "midnight-sun" => ActivityBand.Quiet,
+ _ => ActivityBand.PlayersNow,
+ };
- if (filter.MeasuredProtocols.Count > 0)
- {
- games = games.Where(g => filter.MeasuredProtocols.All(
- p => g.MeasuredProtocols.Contains(p, StringComparer.OrdinalIgnoreCase)));
- }
+ ///
+ /// The negotiated encoding — which most servers never negotiate, so most of these are
+ /// null and land in the facet's "not measured" bucket rather than being filled in from MSSP.
+ ///
+ private static string? Charset(GameSummary g) =>
+ g.MeasuredProtocols.Contains("CHARSET") ? "UTF-8" : null;
- return Task.FromResult>(games.ToList());
- }
+ ///
+ /// MSSP FAMILY, which is the coarse taxonomy CODEBASE's version strings are too
+ /// fine to serve as. BatMUD and Aardwolf declare neither, which is the common case and why the
+ /// facet has an unknown bucket at all.
+ ///
+ private static string? Family(GameSummary g) => g.Codebase switch
+ {
+ var c when c is not null && c.StartsWith("PennMUSH", StringComparison.Ordinal) => "PennMUSH",
+ var c when c is not null && c.StartsWith("TinyMUX", StringComparison.Ordinal) => "TinyMUX",
+ "Evennia" => "Evennia",
+ _ => null,
+ };
///
- /// A game whose counts are all unmeasurable is and never
- /// — being uncountable is not being absent (spec §5.2).
+ /// MSSP GENRE. Midnight Sun declares none, which is the commonest state of a hand-typed
+ /// field and the one a demo of well-behaved games would never show — the facet's unknown bucket
+ /// exists for it, and a fixture with nothing in that bucket does not exercise it.
///
- private static bool InBand(GameSummary g, ActivityBand band) => band switch
+ private static string? Genre(GameSummary g) => g.Slug switch
{
- ActivityBand.PlayersNow => g.PlayersNow > 0,
- ActivityBand.ActiveThisWeek => g.State is LifecycleState.Active,
- ActivityBand.Quiet => g.State is LifecycleState.Quiet
- || (g.State is LifecycleState.Active && g.PlayersNow is null or 0),
- ActivityBand.Dark => g.State is LifecycleState.Dark,
- _ => g.State is LifecycleState.Archived,
+ "ashen-court" => "Historical",
+ "aardwolf" or "batmud" => "Fantasy",
+ "m-u-s-h" or "eldertale" => "Development",
+ _ => null,
};
public Task FindAsync(string slug, CancellationToken cancellationToken = default)
diff --git a/src/MUI.Web/wwwroot/app.css b/src/MUI.Web/wwwroot/app.css
index ee4d5b0..862c686 100644
--- a/src/MUI.Web/wwwroot/app.css
+++ b/src/MUI.Web/wwwroot/app.css
@@ -596,4 +596,55 @@ 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.
+
+ 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
+ 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.
+ ═══════════════════════════════════════════════════════════════════════════ */
+
+form.facet-form { margin-bottom: var(--gap); }
+
+.facet-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
+ gap: 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; }
+
+fieldset.facet.presence {
+ border: 1px solid var(--line);
+ border-radius: 4px;
+ padding: 6px 8px 8px;
+ 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; }
+
+/* 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.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; }
+
+/* A filter we could not read is refused out loud rather than dropped, so it needs somewhere loud
+ to be said. Amber, like every other "we are not showing you a measurement" state here. */
+.problem {
+ padding: var(--cpad);
+ border-left: 3px solid var(--amber);
+ background: color-mix(in srgb, var(--amber) 10%, transparent);
}
diff --git a/tests/MUI.Catalog.Tests/FacetedSearchTests.cs b/tests/MUI.Catalog.Tests/FacetedSearchTests.cs
new file mode 100644
index 0000000..7a0f50a
--- /dev/null
+++ b/tests/MUI.Catalog.Tests/FacetedSearchTests.cs
@@ -0,0 +1,310 @@
+namespace MUI.Catalog.Tests;
+
+///
+/// The rules the facet panel is built on, asserted with no database and no markup in the way.
+///
+///
+/// Two of these are the design and not a detail. A count is what a click returns — the whole
+/// justification for computing the counts in the same pass as the listing — so each one is checked
+/// by running the filter it advertises and comparing sizes, which is the only assertion that would
+/// have caught a denominator quietly borrowed from somewhere broader. And an unknown is not a
+/// no: a game we have no genre for must be findable as such and must never be returned by a
+/// choice of some other genre, because folding the two is the single failure this site exists to
+/// stop making about everything else.
+///
+public class FacetedSearchTests
+{
+ private static readonly DateTimeOffset Now = new(2026, 7, 30, 12, 0, 0, TimeSpan.Zero);
+
+ private static GameFacetRow Row(
+ string slug,
+ ActivityBand band = ActivityBand.PlayersNow,
+ string? genre = null,
+ string? codebase = null,
+ string? charset = null,
+ bool tls = false,
+ DateTimeOffset? lastReachableAt = null,
+ string[]? protocols = null)
+ {
+ var summary = new GameSummary(
+ Guid.NewGuid(), slug, slug, Tagline: null, LifecycleState.Active, IsClaimed: false,
+ PlayersNow: 1, codebase, protocols ?? [], lastReachableAt ?? Now);
+
+ return new GameFacetRow(
+ summary,
+ band,
+ FacetedSearch.LastSeenOf(lastReachableAt ?? Now, Now),
+ tls,
+ charset,
+ Language: null,
+ codebase,
+ Family: null,
+ genre);
+ }
+
+ private static FacetGroup Group(GameListing listing, string key) =>
+ listing.Facets.Single(f => f.Key == key);
+
+ private static FacetValue Value(GameListing listing, string key, string token) =>
+ Group(listing, key).Values.Single(v => v.Token == token);
+
+ [Test]
+ public async Task EveryCountIsExactlyWhatChoosingThatValueReturns()
+ {
+ // The claim the panel makes, checked by making the panel's own promise and then keeping it.
+ // Nothing else here would notice a count measured against a wider set than the click lands on.
+ GameFacetRow[] rows =
+ [
+ Row("a", genre: "Fantasy", codebase: "Evennia", protocols: ["GMCP"]),
+ Row("b", genre: "Fantasy", codebase: "PennMUSH 1.8.8p0"),
+ Row("c", genre: "Historical", codebase: "Evennia", protocols: ["GMCP"], tls: true),
+ Row("d", codebase: "Evennia"),
+ ];
+
+ var listing = FacetedSearch.Search(rows, new GameFilter());
+
+ foreach (var group in listing.Facets)
+ {
+ foreach (var value in group.Values)
+ {
+ var chosen = FacetedSearch.Search(rows, Choose(group.Key, value.Token));
+
+ await Assert.That(chosen.Games.Count)
+ .IsEqualTo(value.Count)
+ .Because($"choosing {group.Key}={value.Token} must return the number it advertises");
+ }
+ }
+ }
+
+ [Test]
+ public async Task ACountStaysTrueWhenAnotherFacetIsAlreadyChosen()
+ {
+ // The harder half: a facet's values are counted against the rest of the filter, so the
+ // number beside "Evennia" while genre=Fantasy is chosen is the intersection and not the
+ // total. Counting against everything would over-promise on exactly the second click.
+ GameFacetRow[] rows =
+ [
+ Row("a", genre: "Fantasy", codebase: "Evennia"),
+ Row("b", genre: "Fantasy", codebase: "PennMUSH 1.8.8p0"),
+ Row("c", genre: "Historical", codebase: "Evennia"),
+ Row("d", genre: "Historical", codebase: "Evennia"),
+ ];
+
+ var filter = new GameFilter { Genre = FacetChoice.Of("Fantasy") };
+ var listing = FacetedSearch.Search(rows, filter);
+
+ await Assert.That(Value(listing, FacetKeys.Codebase, "Evennia").Count).IsEqualTo(1);
+ await Assert.That(
+ FacetedSearch.Search(rows, filter with { Codebase = FacetChoice.Of("Evennia") }).Games.Count)
+ .IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task AFacetsOwnSelectionIsLiftedSoTheOtherValuesAreStillReachable()
+ {
+ // A choice facet replaces rather than intersects, so its siblings have to be counted with it
+ // out of the way — counted against the results they would all read zero and the panel would
+ // be a one-way door.
+ GameFacetRow[] rows =
+ [
+ Row("a", genre: "Fantasy"),
+ Row("b", genre: "Historical"),
+ Row("c", genre: "Historical"),
+ ];
+
+ var listing = FacetedSearch.Search(rows, new GameFilter { Genre = FacetChoice.Of("Fantasy") });
+
+ await Assert.That(Value(listing, FacetKeys.Genre, "Fantasy").IsSelected).IsTrue();
+ await Assert.That(Value(listing, FacetKeys.Genre, "Historical").Count).IsEqualTo(2);
+ await Assert.That(Group(listing, FacetKeys.Genre).Total).IsEqualTo(3);
+ }
+
+ [Test]
+ public async Task AGameWithNoValueIsUnknownAndIsNeverReturnedByAnotherValue()
+ {
+ // "We have no genre for this game" and "this game's genre is not Fantasy" are different
+ // facts. The first is askable, and asking the second must not hand back the first.
+ GameFacetRow[] rows =
+ [
+ Row("known", genre: "Fantasy"),
+ Row("silent"),
+ ];
+
+ var listing = FacetedSearch.Search(rows, new GameFilter());
+ var unknown = Value(listing, FacetKeys.Genre, FacetChoice.UnknownToken);
+
+ await Assert.That(unknown.IsUnknown).IsTrue();
+ await Assert.That(unknown.Count).IsEqualTo(1);
+
+ var asked = FacetedSearch.Search(rows, new GameFilter { Genre = FacetChoice.Unknown });
+ var elsewhere = FacetedSearch.Search(rows, new GameFilter { Genre = FacetChoice.Of("Fantasy") });
+
+ await Assert.That(asked.Games.Select(g => g.Slug).ToList()).IsEquivalentTo(new[] { "silent" });
+ await Assert.That(elsewhere.Games.Select(g => g.Slug).ToList()).IsEquivalentTo(new[] { "known" });
+ }
+
+ [Test]
+ public async Task AProtocolNobodyWasSeenOfferingIsNotOfferedAsAChoice()
+ {
+ // A facet that can be clicked into an empty listing is a facet lying about the catalogue.
+ // Not drawing the click is the cheapest way to make that impossible.
+ var listing = FacetedSearch.Search([Row("a", protocols: ["GMCP"])], new GameFilter());
+ var protocols = Group(listing, FacetKeys.Protocol).Values.Select(v => v.Token).ToList();
+
+ await Assert.That(protocols).IsEquivalentTo(new[] { "GMCP" });
+ }
+
+ [Test]
+ public async Task ProtocolsIntersectAndTheTickBoxNeverMeansTheGameLacksIt()
+ {
+ // Ticking two asks for games seen offering both. There is deliberately no way to ask for the
+ // complement: a capability is written only when observed, so "not listed" covers a game we
+ // never measured as well as one that declined, and only one of those is a fact about them.
+ GameFacetRow[] rows =
+ [
+ Row("both", protocols: ["GMCP", "MSSP"]),
+ Row("one", protocols: ["MSSP"]),
+ ];
+
+ var listing = FacetedSearch.Search(rows, new GameFilter { MeasuredProtocols = ["GMCP", "MSSP"] });
+
+ await Assert.That(listing.Games.Select(g => g.Slug).ToList()).IsEquivalentTo(new[] { "both" });
+
+ // With GMCP ticked, MSSP's count is the listing itself — what unticking MSSP would leave.
+ var mssp = Value(FacetedSearch.Search(rows, new GameFilter { MeasuredProtocols = ["GMCP"] }),
+ FacetKeys.Protocol, "MSSP");
+ await Assert.That(mssp.Count).IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task AskingForTheArchivedBandLiftsTheArchiveExclusionByItself()
+ {
+ // The one filter the database and the demo fixture used to answer differently, which is why
+ // both now come through this function. Archived games leave the default listing and nothing
+ // else (spec §7.5), and choosing the band that names them is not the default listing.
+ GameFacetRow[] rows =
+ [
+ Row("live"),
+ Row("gone", band: ActivityBand.Archived),
+ ];
+
+ var listing = FacetedSearch.Search(rows, new GameFilter { Band = ActivityBand.Archived });
+
+ await Assert.That(listing.Games.Select(g => g.Slug).ToList()).IsEquivalentTo(new[] { "gone" });
+ await Assert.That(FacetedSearch.Search(rows, new GameFilter()).Games.Count).IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task NeverReachedIsItsOwnBandAndNotTheOldestOne()
+ {
+ // A game we have listed and never once got an answer from has no last-seen date. Dating it
+ // from our own first sighting would publish our ignorance as its outage.
+ GameFacetRow[] rows =
+ [
+ Row("fresh", lastReachableAt: Now.AddHours(-2)),
+ Row("stale", lastReachableAt: Now.AddDays(-200)),
+ new(
+ new GameSummary(
+ Guid.NewGuid(), "silent", "Silent", null, LifecycleState.Active, false, null, null, [],
+ null),
+ ActivityBand.Dark,
+ FacetedSearch.LastSeenOf(null, Now),
+ false, null, null, null, null, null),
+ ];
+
+ await Assert.That(FacetedSearch.LastSeenOf(null, Now)).IsEqualTo(LastSeenBand.Never);
+
+ var older = FacetedSearch.Search(rows, new GameFilter { LastSeen = LastSeenBand.Older });
+ var never = FacetedSearch.Search(rows, new GameFilter { LastSeen = LastSeenBand.Never });
+
+ await Assert.That(older.Games.Select(g => g.Slug).ToList()).IsEquivalentTo(new[] { "stale" });
+ await Assert.That(never.Games.Select(g => g.Slug).ToList()).IsEquivalentTo(new[] { "silent" });
+ }
+
+ [Test]
+ public async Task TheLastSeenBandsNestSoTheCommonQuestionIsOneChoice()
+ {
+ GameFacetRow[] rows =
+ [
+ Row("hour", lastReachableAt: Now.AddHours(-1)),
+ Row("days", lastReachableAt: Now.AddDays(-3)),
+ ];
+
+ var listing = FacetedSearch.Search(rows, new GameFilter());
+
+ await Assert.That(Value(listing, FacetKeys.LastSeen, "day").Count).IsEqualTo(1);
+ await Assert.That(Value(listing, FacetKeys.LastSeen, "week").Count).IsEqualTo(2);
+ }
+
+ [Test]
+ public async Task TlsIsAMeasuredEndpointAndTheFacetIsAbsentWhenNothingMeasuredOne()
+ {
+ var without = FacetedSearch.Search([Row("plain")], new GameFilter());
+ var with = FacetedSearch.Search([Row("plain"), Row("secure", tls: true)], new GameFilter());
+
+ await Assert.That(without.Facets.Any(f => f.Key == FacetKeys.Tls)).IsFalse();
+ await Assert.That(Value(with, FacetKeys.Tls, "yes").Count).IsEqualTo(1);
+ await Assert.That(
+ FacetedSearch.Search([Row("plain"), Row("secure", tls: true)], new GameFilter { Tls = true })
+ .Games.Select(g => g.Slug).ToList())
+ .IsEquivalentTo(new[] { "secure" });
+ }
+
+ [Test]
+ public async Task EveryTokenAFacetOffersIsOneTheFilterVocabularyCanReadBack()
+ {
+ // The panel emits these and the querystring binding reads them. If the two tables were
+ // separate, a facet could offer a value its own parser would refuse — which is a dead end a
+ // reader would find by clicking, and nothing else would.
+ foreach (var token in FacetTokens.Bands)
+ {
+ await Assert.That(FacetTokens.TryBand(token, out var band)).IsTrue();
+ await Assert.That(FacetTokens.Of(band)).IsEqualTo(token);
+ }
+
+ foreach (var token in FacetTokens.LastSeenBands)
+ {
+ await Assert.That(FacetTokens.TryLastSeen(token, out var seen)).IsTrue();
+ await Assert.That(FacetTokens.Of(seen)).IsEqualTo(token);
+ }
+ }
+
+ [Test]
+ public async Task ANumberIsNotAFacetValueHoweverTheEnumIsOrdered()
+ {
+ // Enum.TryParse accepts the underlying number, which would make band=0 a synonym for
+ // whichever member is declared first — a facet that silently re-points itself the day
+ // somebody reorders the enum.
+ await Assert.That(FacetTokens.TryBand("0", out _)).IsFalse();
+ await Assert.That(FacetTokens.TryLastSeen("4", out _)).IsFalse();
+
+ // Separators are forgiven, because all three spellings are what people type.
+ await Assert.That(FacetTokens.TryBand("active-this-week", out var band)).IsTrue();
+ await Assert.That(band).IsEqualTo(ActivityBand.ActiveThisWeek);
+ }
+
+ private static GameFilter Choose(string key, string token) => key switch
+ {
+ FacetKeys.Band => new GameFilter { Band = Band(token) },
+ FacetKeys.LastSeen => new GameFilter { LastSeen = Seen(token) },
+ FacetKeys.Protocol => new GameFilter { MeasuredProtocols = [token] },
+ FacetKeys.Tls => new GameFilter { Tls = true },
+ FacetKeys.Charset => new GameFilter { Charset = FacetChoice.Parse(token) },
+ FacetKeys.Codebase => new GameFilter { Codebase = FacetChoice.Parse(token) },
+ FacetKeys.Family => new GameFilter { Family = FacetChoice.Parse(token) },
+ FacetKeys.Genre => new GameFilter { Genre = FacetChoice.Parse(token) },
+ _ => new GameFilter { Language = FacetChoice.Parse(token) },
+ };
+
+ private static ActivityBand Band(string token)
+ {
+ FacetTokens.TryBand(token, out var band);
+ return band;
+ }
+
+ private static LastSeenBand Seen(string token)
+ {
+ FacetTokens.TryLastSeen(token, out var seen);
+ return seen;
+ }
+}
diff --git a/tests/MUI.Catalog.Tests/Persistence/FacetQueriesPostgresTests.cs b/tests/MUI.Catalog.Tests/Persistence/FacetQueriesPostgresTests.cs
new file mode 100644
index 0000000..c348cb9
--- /dev/null
+++ b/tests/MUI.Catalog.Tests/Persistence/FacetQueriesPostgresTests.cs
@@ -0,0 +1,250 @@
+using MUI.Catalog.Persistence;
+using MUI.Catalog.Tests.Persistence.Support;
+
+namespace MUI.Catalog.Tests.Persistence;
+
+///
+/// The facets against a real database — which column each one reads, and what it does with silence.
+///
+///
+/// covers the arithmetic with no I/O. What can only be checked here
+/// is the half that reads rows: that capability.gmcp.measured and
+/// capability.gmcp.declared are two columns and the facet reads the first, that a CHARSET
+/// row's source decides whether it counts, and that TLS comes off an endpoint rather than
+/// off a claim. Every one of those is a place where the honest column and the convenient one sit
+/// side by side under nearly the same name.
+///
+public class FacetQueriesPostgresTests
+{
+ private static readonly DateTimeOffset Now = Seed.Now;
+
+ private static NpgsqlGameQueries QueriesOn(TestDatabase db) =>
+ new(db.DataSource) { Clock = () => Now };
+
+ private static FacetGroup? Group(GameListing listing, string key) =>
+ listing.Facets.FirstOrDefault(f => f.Key == key);
+
+ [Test]
+ public async Task TheProtocolFacetCountsWhatWasMeasuredAndNotWhatWasClaimed()
+ {
+ // The central one. Both games "have GMCP" in the loose sense; only one of them was seen
+ // offering it, and a facet that read the declared column would return the pair and call it
+ // measurement — which is the lie the whole schema is shaped to prevent.
+ await using var db = await PostgresFixture.MigratedAsync();
+ var measured = await Seed.GameAsync(db, "measured", "Measured", lastReachableAt: Now);
+ var claimed = await Seed.GameAsync(db, "claimed", "Claimed", lastReachableAt: Now);
+ var fields = new NpgsqlGameFieldStore(db.DataSource);
+
+ await fields.UpsertAsync(new GameField(
+ measured, CapabilityFields.Measured("GMCP"), FieldSource.Handshake, "true", Now, Now));
+ await fields.UpsertAsync(new GameField(
+ claimed, CapabilityFields.Declared("GMCP"), FieldSource.Mssp, "true", Now, Now));
+
+ var listing = await QueriesOn(db).SearchAsync(new GameFilter());
+ var gmcp = Group(listing, FacetKeys.Protocol)!.Values.Single(v => v.Token == "GMCP");
+
+ await Assert.That(gmcp.Count).IsEqualTo(1);
+ await Assert.That(
+ (await QueriesOn(db).ListAsync(new GameFilter { MeasuredProtocols = ["GMCP"] }))
+ .Select(g => g.Slug).ToList())
+ .IsEquivalentTo(new[] { "measured" });
+ }
+
+ [Test]
+ public async Task AGameThatOnlyDeclaredAProtocolIsNotFoldedIntoAnyProtocolAnswer()
+ {
+ // And it is not the other error either: the declaring game is neither counted as offering
+ // GMCP nor recorded anywhere as refusing it. It is simply a game we have not measured, and
+ // the facet has no vocabulary for saying otherwise.
+ await using var db = await PostgresFixture.MigratedAsync();
+ var claimed = await Seed.GameAsync(db, "claimed", "Claimed", lastReachableAt: Now);
+ await new NpgsqlGameFieldStore(db.DataSource).UpsertAsync(new GameField(
+ claimed, CapabilityFields.Declared("GMCP"), FieldSource.Mssp, "true", Now, Now));
+
+ var listing = await QueriesOn(db).SearchAsync(new GameFilter());
+
+ await Assert.That(Group(listing, FacetKeys.Protocol)).IsNull();
+ await Assert.That(listing.Games).Count().IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task TheCharsetFacetReadsWhatWasNegotiatedAndNotWhatMsspClaimed()
+ {
+ // CHARSET is one of the few fields both a handshake and MSSP write, so the precedence winner
+ // is the handshake's when there is one and the game's own assertion when there is not. A
+ // facet labelled "we measured this" must not quietly answer from the second.
+ await using var db = await PostgresFixture.MigratedAsync();
+ var negotiated = await Seed.GameAsync(db, "negotiated", "Negotiated", lastReachableAt: Now);
+ var asserted = await Seed.GameAsync(db, "asserted", "Asserted", lastReachableAt: Now);
+ var fields = new NpgsqlGameFieldStore(db.DataSource);
+
+ await fields.UpsertAsync(new GameField(
+ negotiated, "CHARSET", FieldSource.Handshake, "UTF-8", Now, Now));
+ await fields.UpsertAsync(new GameField(
+ asserted, "CHARSET", FieldSource.Mssp, "UTF-8", Now, Now));
+
+ var listing = await QueriesOn(db).SearchAsync(new GameFilter());
+ var charset = Group(listing, FacetKeys.Charset)!;
+
+ await Assert.That(charset.Values.Single(v => v.Token == "UTF-8").Count).IsEqualTo(1);
+ await Assert.That(charset.Values.Single(v => v.IsUnknown).Count).IsEqualTo(1);
+ await Assert.That(charset.Evidence).IsEqualTo(FacetEvidence.Measured);
+
+ var chosen = await QueriesOn(db).ListAsync(
+ new GameFilter { Charset = FacetChoice.Of("UTF-8") });
+ await Assert.That(chosen.Select(g => g.Slug).ToList()).IsEquivalentTo(new[] { "negotiated" });
+ }
+
+ [Test]
+ public async Task NothingNegotiatedIsItsOwnAnswerAndNotAnAbsenceOfUtf8()
+ {
+ await using var db = await PostgresFixture.MigratedAsync();
+ await Seed.GameAsync(db, "silent", "Silent", lastReachableAt: Now);
+
+ var listing = await QueriesOn(db).SearchAsync(new GameFilter { Charset = FacetChoice.Unknown });
+
+ await Assert.That(listing.Games.Select(g => g.Slug).ToList()).IsEquivalentTo(new[] { "silent" });
+ await Assert.That(Group(listing, FacetKeys.Charset)!.Values.Single().IsUnknown).IsTrue();
+ }
+
+ [Test]
+ public async Task TlsIsAnEndpointWeOpenedAndNeverAnSslLineInMssp()
+ {
+ // capability.ssl.declared says somebody typed SSL 4202 into their configuration. An endpoint
+ // of kind tls says a socket was opened. Only the second is a measurement, and the facet
+ // reads only the second — which is also why it renders nothing today: the crawler dials
+ // plaintext, so nothing writes a TLS endpoint yet.
+ await using var db = await PostgresFixture.MigratedAsync();
+ var secure = await Seed.GameAsync(db, "secure", "Secure", lastReachableAt: Now);
+ var boastful = await Seed.GameAsync(db, "boastful", "Boastful", lastReachableAt: Now);
+
+ await new NpgsqlEndpointStore(db.DataSource).UpsertAsync(new GameEndpoint(
+ secure, "secure.example", 4202, EndpointKind.Tls, Now, Now, EndpointState.Active));
+ await new NpgsqlGameFieldStore(db.DataSource).UpsertAsync(new GameField(
+ boastful, CapabilityFields.Declared("SSL"), FieldSource.Mssp, "true", Now, Now));
+
+ var listing = await QueriesOn(db).SearchAsync(new GameFilter());
+
+ await Assert.That(Group(listing, FacetKeys.Tls)!.Values.Single().Count).IsEqualTo(1);
+ await Assert.That(
+ (await QueriesOn(db).ListAsync(new GameFilter { Tls = true })).Select(g => g.Slug).ToList())
+ .IsEquivalentTo(new[] { "secure" });
+ }
+
+ [Test]
+ public async Task ACodebaseWeCouldNotIdentifyIsItsOwnBucketAndCanBeAskedFor()
+ {
+ // A measurement of our own reach, and one of the more useful filters in the panel. It also
+ // survives the cap on open-ended facets, because it is exactly the value a popularity cut
+ // would delete on a well-covered catalogue.
+ await using var db = await PostgresFixture.MigratedAsync();
+ var known = await Seed.GameAsync(db, "known", "Known", lastReachableAt: Now);
+ await Seed.GameAsync(db, "mystery", "Mystery", lastReachableAt: Now);
+
+ await new NpgsqlGameFieldStore(db.DataSource).UpsertAsync(new GameField(
+ known, "CODEBASE", FieldSource.Mssp, "Evennia", Now, Now));
+
+ var listing = await QueriesOn(db).SearchAsync(new GameFilter());
+ var codebase = Group(listing, FacetKeys.Codebase)!;
+
+ await Assert.That(codebase.Evidence).IsEqualTo(FacetEvidence.Declared);
+ await Assert.That(codebase.Values.Single(v => v.IsUnknown).Count).IsEqualTo(1);
+ await Assert.That(
+ (await QueriesOn(db).ListAsync(new GameFilter { Codebase = FacetChoice.Unknown }))
+ .Select(g => g.Slug).ToList())
+ .IsEquivalentTo(new[] { "mystery" });
+ }
+
+ [Test]
+ public async Task TheArchivedBandLiftsTheArchiveExclusionInTheDatabaseToo()
+ {
+ // This is the divergence the shared search was extracted to close: the demo fixture read
+ // band=archived as asking for the archive and this class read it as a filter over a listing
+ // the archive had already left, so one filter had two answers.
+ await using var db = await PostgresFixture.MigratedAsync();
+ await Seed.GameAsync(db, "corvid", "Corvid", lastReachableAt: Now);
+ await Seed.GameAsync(db, "gaslight-row", "Gaslight Row", LifecycleState.Archived);
+
+ var archived = await QueriesOn(db).ListAsync(new GameFilter { Band = ActivityBand.Archived });
+
+ await Assert.That(archived.Select(g => g.Slug).ToList()).IsEquivalentTo(new[] { "gaslight-row" });
+ }
+
+ [Test]
+ public async Task TheLastSeenFacetCarriesTheDateItFilteredOnOntoTheRowsItReturned()
+ {
+ // A facet whose value cannot be read off its own results is one a reader has to take on
+ // trust. Never reached stays null rather than being dated from our first sighting.
+ await using var db = await PostgresFixture.MigratedAsync();
+ await Seed.GameAsync(db, "fresh", "Fresh", lastReachableAt: Now.AddHours(-2));
+ await Seed.GameAsync(db, "silent", "Silent");
+
+ var listing = await QueriesOn(db).SearchAsync(new GameFilter());
+ var byslug = listing.Games.ToDictionary(g => g.Slug);
+ var seen = Group(listing, FacetKeys.LastSeen)!;
+
+ await Assert.That(byslug["fresh"].LastReachableAt).IsEqualTo(Now.AddHours(-2));
+ await Assert.That(byslug["silent"].LastReachableAt).IsNull();
+ await Assert.That(seen.Values.Single(v => v.Token == "never").Count).IsEqualTo(1);
+ await Assert.That(seen.Values.Single(v => v.Token == "day").Count).IsEqualTo(1);
+ }
+
+ [Test]
+ public async Task EveryCountTheDatabasePublishesIsWhatChoosingThatValueReturns()
+ {
+ // The panel's promise, kept end to end: run every advertised choice back through the real
+ // query and check the listing is the size the facet said it would be.
+ await using var db = await PostgresFixture.MigratedAsync();
+ var penn = await Seed.GameAsync(db, "penn", "Penn", lastReachableAt: Now);
+ var evennia = await Seed.GameAsync(db, "evennia", "Evennia game", lastReachableAt: Now.AddDays(-10));
+ await Seed.GameAsync(db, "quiet-one", "Quiet one", lastReachableAt: Now.AddDays(-200));
+ var fields = new NpgsqlGameFieldStore(db.DataSource);
+
+ await fields.UpsertAsync(new GameField(penn, "CODEBASE", FieldSource.Mssp, "PennMUSH", Now, Now));
+ await fields.UpsertAsync(new GameField(penn, "GENRE", FieldSource.Mssp, "Fantasy", Now, Now));
+ await fields.UpsertAsync(new GameField(
+ evennia, "CODEBASE", FieldSource.Mssp, "Evennia", Now, Now));
+ await fields.UpsertAsync(new GameField(
+ penn, CapabilityFields.Measured("MSSP"), FieldSource.Handshake, "true", Now, Now));
+
+ var queries = QueriesOn(db);
+ var listing = await queries.SearchAsync(new GameFilter());
+
+ foreach (var group in listing.Facets)
+ {
+ foreach (var value in group.Values)
+ {
+ var games = await queries.ListAsync(Choose(group.Key, value.Token));
+
+ await Assert.That(games.Count)
+ .IsEqualTo(value.Count)
+ .Because($"{group.Key}={value.Token} advertised {value.Count}");
+ }
+ }
+ }
+
+ private static GameFilter Choose(string key, string token) => key switch
+ {
+ FacetKeys.Band => new GameFilter { Band = Band(token) },
+ FacetKeys.LastSeen => new GameFilter { LastSeen = Seen(token) },
+ FacetKeys.Protocol => new GameFilter { MeasuredProtocols = [token] },
+ FacetKeys.Tls => new GameFilter { Tls = true },
+ FacetKeys.Charset => new GameFilter { Charset = FacetChoice.Parse(token) },
+ FacetKeys.Codebase => new GameFilter { Codebase = FacetChoice.Parse(token) },
+ FacetKeys.Family => new GameFilter { Family = FacetChoice.Parse(token) },
+ FacetKeys.Genre => new GameFilter { Genre = FacetChoice.Parse(token) },
+ _ => new GameFilter { Language = FacetChoice.Parse(token) },
+ };
+
+ private static ActivityBand Band(string token)
+ {
+ FacetTokens.TryBand(token, out var band);
+ return band;
+ }
+
+ private static LastSeenBand Seen(string token)
+ {
+ FacetTokens.TryLastSeen(token, out var seen);
+ return seen;
+ }
+}
diff --git a/tests/MUI.Web.Tests/Api/FacetApiTests.cs b/tests/MUI.Web.Tests/Api/FacetApiTests.cs
new file mode 100644
index 0000000..4c5a59f
--- /dev/null
+++ b/tests/MUI.Web.Tests/Api/FacetApiTests.cs
@@ -0,0 +1,137 @@
+using System.Text.Json;
+
+using MUI.Catalog;
+using MUI.Web.Api;
+
+namespace MUI.Web.Tests.Api;
+
+///
+/// /api/games's facets — the same counts the page shows, over the same querystring.
+///
+///
+/// The point of publishing them at all is that a consumer building a filter UI over this endpoint
+/// gets the guarantee the site's own panel has: every count is what choosing that value returns.
+/// The test that matters here is therefore the round trip — take each count the API advertised, ask
+/// the API for that value, and check the answer is the size it promised. A shape assertion would
+/// pass on a number computed against any set at all.
+///
+public class FacetApiTests
+{
+ [Test]
+ public async Task EveryCountTheApiPublishesIsWhatAskingForThatValueReturns()
+ {
+ await using var host = await ApiHost.StartAsync();
+
+ var listing = await Json.ElementAsync(await host.Client.GetAsync(ApiRoutes.Games));
+
+ foreach (var group in listing.GetProperty("facets").EnumerateArray())
+ {
+ var key = group.GetProperty("key").GetString()!;
+
+ foreach (var value in group.GetProperty("values").EnumerateArray())
+ {
+ var token = value.GetProperty("value").GetString()!;
+ var promised = value.GetProperty("count").GetInt32();
+
+ var chosen = await Json.ElementAsync(await host.Client.GetAsync(
+ $"{ApiRoutes.Games}?{key}={Uri.EscapeDataString(token)}"));
+
+ await Assert.That(chosen.GetProperty("total").GetInt32())
+ .IsEqualTo(promised)
+ .Because($"{key}={token} advertised {promised}");
+ }
+ }
+ }
+
+ [Test]
+ public async Task TheFacetsSayWhichSideOfTheEvidenceEachOfThemReads()
+ {
+ // Published rather than left to be inferred. A facet reading capability.gmcp.measured and one
+ // reading MSSP's GENRE are not the same kind of statement, and a client that presented them
+ // identically would be making the claim this site exists to stop making.
+ await using var host = await ApiHost.StartAsync();
+
+ var listing = await Json.ElementAsync(await host.Client.GetAsync(ApiRoutes.Games));
+ var byKey = listing.GetProperty("facets").EnumerateArray()
+ .ToDictionary(g => g.GetProperty("key").GetString()!);
+
+ await Assert.That(byKey[FacetKeys.Protocol].GetProperty("evidence").GetString())
+ .IsEqualTo("measured");
+ await Assert.That(byKey[FacetKeys.Genre].GetProperty("evidence").GetString())
+ .IsEqualTo("declared");
+ }
+
+ [Test]
+ public async Task AnUnknownIsPublishedAsAnUnknownAndNeverAsAnAbsentValue()
+ {
+ // A consumer that read the missing games as "no genre" would be doing exactly what the
+ // measured/declared split exists to prevent, one layer out. The flag is on the value so it
+ // cannot be inferred from the token's spelling.
+ await using var host = await ApiHost.StartAsync();
+
+ var listing = await Json.ElementAsync(await host.Client.GetAsync(ApiRoutes.Games));
+ var genre = listing.GetProperty("facets").EnumerateArray()
+ .Single(g => g.GetProperty("key").GetString() == FacetKeys.Genre);
+
+ var unknown = genre.GetProperty("values").EnumerateArray()
+ .Single(v => v.GetProperty("unknown").GetBoolean());
+
+ await Assert.That(unknown.GetProperty("value").GetString()).IsEqualTo(FacetChoice.UnknownToken);
+ await Assert.That(unknown.GetProperty("count").GetInt32()).IsGreaterThan(0);
+ }
+
+ [Test]
+ public async Task TheEchoedFilterCarriesEveryFacetTheRequestSet()
+ {
+ // A cached body has to say which question it is the answer to, and "which question" now has
+ // more than two parts.
+ await using var host = await ApiHost.StartAsync();
+
+ var listing = await Json.ElementAsync(await host.Client.GetAsync(
+ $"{ApiRoutes.Games}?band=quiet&seen=week&language=English&codebase=~unknown&tls=true"));
+
+ var echo = listing.GetProperty("filter");
+
+ // The echoed enum and the facet token are the same word, which is the point: what the panel
+ // put in the URL is what the API says it answered.
+ await Assert.That(echo.GetProperty("band").GetString())
+ .IsEqualTo(FacetTokens.Of(ActivityBand.Quiet));
+ await Assert.That(echo.GetProperty("seen").GetString())
+ .IsEqualTo(FacetTokens.Of(LastSeenBand.Week));
+ await Assert.That(echo.GetProperty("language").GetString()).IsEqualTo("English");
+ await Assert.That(echo.GetProperty("codebase").GetString()).IsEqualTo(FacetChoice.UnknownToken);
+ await Assert.That(echo.GetProperty("tls").GetBoolean()).IsTrue();
+ }
+
+ [Test]
+ public async Task AnUnreadableLastSeenBandIsRefusedTheSameWayAnActivityBandIs()
+ {
+ await using var host = await ApiHost.StartAsync();
+
+ var response = await host.Client.GetAsync($"{ApiRoutes.Games}?seen=someday");
+
+ await Assert.That((int)response.StatusCode).IsEqualTo(400);
+ await Assert.That((await Json.ElementAsync(response)).GetProperty("detail").GetString())
+ .Contains("never");
+ }
+
+ [Test]
+ public async Task TheListingSaysWhenEachGameWasLastReached()
+ {
+ // The column the last-seen facet filters on, on the rows it returned. Null is never reached
+ // and is not the oldest bucket — a consumer coercing it to a date would publish our own
+ // ignorance as somebody's outage.
+ await using var host = await ApiHost.StartAsync();
+
+ var listing = await Json.ElementAsync(await host.Client.GetAsync(ApiRoutes.Games));
+ var games = listing.GetProperty("games").EnumerateArray().ToList();
+
+ await Assert.That(games).IsNotEmpty();
+
+ foreach (var game in games)
+ {
+ var seen = game.GetProperty("lastReachableAt");
+ await Assert.That(seen.ValueKind is JsonValueKind.Null or JsonValueKind.String).IsTrue();
+ }
+ }
+}
diff --git a/tests/MUI.Web.Tests/FacetSurfaceTests.cs b/tests/MUI.Web.Tests/FacetSurfaceTests.cs
new file mode 100644
index 0000000..1819839
--- /dev/null
+++ b/tests/MUI.Web.Tests/FacetSurfaceTests.cs
@@ -0,0 +1,293 @@
+using System.Reflection;
+
+using MUI.Catalog;
+using MUI.Web.Api;
+using MUI.Web.Components;
+using MUI.Web.Fixtures;
+
+namespace MUI.Web.Tests;
+
+///
+/// The facet panel where a reader meets it: one vocabulary, a linkable URL, and unknowns that say
+/// what they are.
+///
+///
+/// The page and the read API share , so most of what could go wrong
+/// between them is a naming slip rather than a logic error — which is why the first tests here walk
+/// by reflection instead of listing the facets by hand. A facet added to the
+/// query and forgotten in the parser is exactly the drift the shared vocabulary exists to prevent,
+/// and it is invisible to any test that only exercises the facets somebody remembered.
+///
+public class FacetSurfaceTests
+{
+ private static readonly FixtureGameQueries Queries = new();
+
+ private static IReadOnlyList Keys() =>
+ [
+ .. typeof(FacetKeys)
+ .GetFields(BindingFlags.Public | BindingFlags.Static)
+ .Where(f => f.IsLiteral)
+ .Select(f => (string)f.GetRawConstantValue()!),
+ ];
+
+ /// A value each facet can be given, so a key can be checked for being read at all.
+ private static string Sample(string key) => key switch
+ {
+ FacetKeys.Text => "corvid",
+ FacetKeys.Archived => "true",
+ FacetKeys.Tls => "true",
+ FacetKeys.Band => "quiet",
+ FacetKeys.LastSeen => "week",
+ FacetKeys.Protocol => "GMCP",
+ _ => "something",
+ };
+
+ [Test]
+ public async Task EveryFacetTheCatalogueNamesIsOneTheFilterBindingReads()
+ {
+ // The pin on "one parser, two callers". A key added to FacetKeys and wired into the query
+ // but not into the binding would give the page a facet the API cannot express and the URL
+ // cannot carry — and it would fail silently, as an option that does nothing.
+ GameFilterBinding.TryRead(string.Empty, out var unfiltered, out _);
+
+ foreach (var key in Keys())
+ {
+ var read = GameFilterBinding.TryRead($"?{key}={Sample(key)}", out var query, out var error);
+
+ await Assert.That(read).IsTrue().Because($"{key}: {error}");
+ await Assert.That(Describe(query.Filter))
+ .IsNotEqualTo(Describe(unfiltered.Filter))
+ .Because($"?{key}= changed nothing, so nothing reads it");
+ }
+ }
+
+ [Test]
+ public async Task EveryFacetTheListingReturnsIsOneTheVocabularyNames()
+ {
+ // The other direction: a group the query invents a key for would render a control whose
+ // name means nothing to the parser, so the click would silently do nothing.
+ var listing = await Queries.SearchAsync(new GameFilter { IncludeArchived = true });
+ var keys = Keys();
+
+ await Assert.That(listing.Facets).IsNotEmpty();
+
+ foreach (var group in listing.Facets)
+ {
+ await Assert.That(keys).Contains(group.Key);
+ }
+ }
+
+ [Test]
+ public async Task AFilteredUrlRoundTripsThroughTheFilterAndBackToTheSameWords()
+ {
+ // A filtered listing has to be linkable, so the URL is the state and nothing else is. What
+ // goes in comes back out, including ~unknown, which is a selection and not an empty one.
+ const string Url = "?q=corvid&archived=true&band=quiet&seen=week&protocol=GMCP,MSSP"
+ + "&tls=true&charset=UTF-8&codebase=Evennia&family=PennMUSH&genre=Fantasy&language=~unknown";
+
+ await Assert.That(GameFilterBinding.TryRead(Url, out var query, out _)).IsTrue();
+
+ var f = query.Filter;
+ await Assert.That(f.Text).IsEqualTo("corvid");
+ await Assert.That(f.IncludeArchived).IsTrue();
+ await Assert.That(f.Tls).IsTrue();
+ await Assert.That(f.Band).IsEqualTo(ActivityBand.Quiet);
+ await Assert.That(f.LastSeen).IsEqualTo(LastSeenBand.Week);
+ await Assert.That(f.MeasuredProtocols).IsEquivalentTo(new[] { "GMCP", "MSSP" });
+ await Assert.That(f.Charset!.Value).IsEqualTo("UTF-8");
+ await Assert.That(f.Codebase!.Value).IsEqualTo("Evennia");
+ await Assert.That(f.Family!.Value).IsEqualTo("PennMUSH");
+ await Assert.That(f.Genre!.Value).IsEqualTo("Fantasy");
+ await Assert.That(f.Language!.IsUnknown).IsTrue();
+
+ // The echo is built from the filter rather than the query, so this is the trip back.
+ var echo = query.Echo;
+ await Assert.That(echo.Q).IsEqualTo("corvid");
+ await Assert.That(echo.Band).IsEqualTo(ActivityBand.Quiet);
+ await Assert.That(echo.Seen).IsEqualTo(LastSeenBand.Week);
+ await Assert.That(echo.Charset).IsEqualTo("UTF-8");
+ await Assert.That(echo.Language).IsEqualTo(FacetChoice.UnknownToken);
+ await Assert.That(echo.Tls).IsTrue();
+ }
+
+ [Test]
+ public async Task AnUnknownSelectionIsNotTheSameAsNoSelection()
+ {
+ // A blank parameter asks for anything; ~unknown asks for the games that have nothing. Folding
+ // them together would make "codebase we could not identify" unaskable and would quietly
+ // re-point every URL that asked it.
+ await Assert.That(GameFilterBinding.TryRead("?codebase=", out var blank, out _)).IsTrue();
+ await Assert.That(GameFilterBinding.TryRead("?codebase=~unknown", out var none, out _)).IsTrue();
+
+ await Assert.That(blank.Filter.Codebase).IsNull();
+ await Assert.That(none.Filter.Codebase!.IsUnknown).IsTrue();
+ }
+
+ [Test]
+ public async Task AnUnreadableFacetIsRefusedRatherThanQuietlyDropped()
+ {
+ // Listing the whole catalogue under a filter that did not apply presents our own parse
+ // failure as somebody's answer — the same rule that stops an unparseable WHO reading as zero.
+ await Assert.That(GameFilterBinding.TryRead("?band=wat", out _, out var band)).IsFalse();
+ await Assert.That(band).Contains("activeThisWeek");
+
+ await Assert.That(GameFilterBinding.TryRead("?seen=someday", out _, out var seen)).IsFalse();
+ await Assert.That(seen).Contains("never");
+ }
+
+ [Test]
+ public async Task ThePanelIsAPlainGetFormWithAControlPerFacet()
+ {
+ // No script, so the querystring is the state: the back button works, a filtered listing is
+ // linkable, and the server recomputes every count on every request.
+ var html = await PanelAsync(new GameFilter());
+
+ await Assert.That(html).Contains("method=\"get\"");
+ await Assert.That(html).Contains("action=\"/games\"");
+ await Assert.That(html).DoesNotContain("