Facets { get; set; } = [];
[Parameter, EditorRequired] public GameFilter Filter { get; set; } = new();
diff --git a/src/MUI.Web/Components/Pages/Games.razor b/src/MUI.Web/Components/Pages/Games.razor
index 9689e31..aa7096b 100644
--- a/src/MUI.Web/Components/Pages/Games.razor
+++ b/src/MUI.Web/Components/Pages/Games.razor
@@ -30,14 +30,14 @@ else
{
- @if (Filter.CodebaseFamily is { } family)
+ @if (Filter.CodebaseFamily is { Exclude: false, Value: { } family })
{
@* The filter a reference page links in on. It is a filter and not a search, so it says
which family it is showing and offers the way back out — a reader who arrived from
/reference/codebases/pennmush should not have to guess why the listing is short. *@
codebase @family ·
- what this codebase is ·
+ what this codebase is ·
every game
}
diff --git a/src/MUI.Web/Reference/ReferenceFigures.cs b/src/MUI.Web/Reference/ReferenceFigures.cs
index 6de4067..ccfdf04 100644
--- a/src/MUI.Web/Reference/ReferenceFigures.cs
+++ b/src/MUI.Web/Reference/ReferenceFigures.cs
@@ -33,7 +33,7 @@ public static async Task ReadAsync(
ArgumentNullException.ThrowIfNull(queries);
var games = await queries.ListAsync(
- new GameFilter { CodebaseFamily = family, IncludeArchived = true },
+ new GameFilter { CodebaseFamily = FacetChoice.Of(family), IncludeArchived = true },
cancellationToken);
return new CodebaseFigures(
diff --git a/tests/MUI.Catalog.Tests/FacetPolarityTests.cs b/tests/MUI.Catalog.Tests/FacetPolarityTests.cs
new file mode 100644
index 0000000..4426bcc
--- /dev/null
+++ b/tests/MUI.Catalog.Tests/FacetPolarityTests.cs
@@ -0,0 +1,163 @@
+using MUI.Catalog;
+
+namespace MUI.Catalog.Tests;
+
+///
+/// The third state a facet can be in: filtered out (spec §9).
+///
+///
+/// Absent means the facet is not being asked about, a value means only these, and an
+/// excluded value means anything but these. Without the third, "show me the games that are
+/// not Evennia" is a question the catalogue can answer and the interface cannot ask.
+///
+public class FacetPolarityTests
+{
+ private static readonly GameSummary Penn = Game("penn", "PennMUSH 1.8.8p0");
+ private static readonly GameSummary Evennia = Game("evennia", "Evennia");
+ private static readonly GameSummary Rom = Game("rom", "ROM");
+ private static readonly GameSummary Nameless = Game("nameless", null);
+
+ [Test]
+ public async Task ExcludingAValueReturnsEverythingElse()
+ {
+ var listing = Search(new GameFilter { Codebase = FacetChoice.Not("Evennia") });
+
+ await Assert.That(listing.Games.Select(g => g.Slug))
+ .IsEquivalentTo(new[] { "penn", "rom", "nameless" });
+ }
+
+ [Test]
+ public async Task IncludingAValueStillReturnsOnlyIt()
+ {
+ var listing = Search(new GameFilter { Codebase = FacetChoice.Of("Evennia") });
+
+ await Assert.That(listing.Games.Select(g => g.Slug)).IsEquivalentTo(new[] { "evennia" });
+ }
+
+ [Test]
+ public async Task NoSelectionFiltersOnNothing()
+ {
+ await Assert.That(Search(new GameFilter()).Games.Count).IsEqualTo(4);
+ }
+
+ ///
+ /// A game with no value for the facet survives an exclusion, because it is not the thing excluded.
+ ///
+ ///
+ /// The tempting bug is to treat "not Evennia" as "has a codebase, and it is not Evennia", which
+ /// would quietly drop every game whose codebase we never identified — turning our own gap in
+ /// measurement into a property of those games. Not identifying a codebase is a measurement, and
+ /// it is not a measurement of being Evennia.
+ ///
+ [Test]
+ public async Task AGameWithNoValueSurvivesAnExclusion()
+ {
+ var listing = Search(new GameFilter { Codebase = FacetChoice.Not("Evennia") });
+
+ await Assert.That(listing.Games.Select(g => g.Slug)).Contains("nameless");
+ }
+
+ /// Excluding the unknown is its own question, and answers it.
+ [Test]
+ public async Task TheUnknownCanItselfBeExcluded()
+ {
+ var listing = Search(new GameFilter
+ {
+ Codebase = FacetChoice.Unknown with { Exclude = true },
+ });
+
+ await Assert.That(listing.Games.Select(g => g.Slug))
+ .IsEquivalentTo(new[] { "penn", "evennia", "rom" });
+ }
+
+ /// The family filter is a bounded prefix, and it inverts the same way.
+ [Test]
+ public async Task ExcludingACodebaseFamilyTakesEveryPatchlevelWithIt()
+ {
+ var listing = Search(new GameFilter { CodebaseFamily = FacetChoice.Not("PennMUSH") });
+
+ // PennMUSH 1.8.8p0 goes with the family it belongs to; ROM stays, and is not caught by a
+ // prefix that would have gathered ROMulus.
+ await Assert.That(listing.Games.Select(g => g.Slug))
+ .IsEquivalentTo(new[] { "evennia", "rom", "nameless" });
+ }
+
+ /// A token round-trips through the URL with its polarity intact.
+ [Test]
+ public async Task PolaritySurvivesTheQuerystring()
+ {
+ foreach (var choice in new[]
+ {
+ FacetChoice.Of("Evennia"),
+ FacetChoice.Not("Evennia"),
+ FacetChoice.Unknown,
+ FacetChoice.Unknown with { Exclude = true },
+ })
+ {
+ await Assert.That(FacetChoice.Parse(choice.Token)).IsEqualTo(choice);
+ }
+ }
+
+ ///
+ /// A value that begins with the marker stays reachable rather than reading as its own negation.
+ ///
+ [Test]
+ public async Task AValueBeginningWithTheMarkerIsNotMistakenForAnExclusion()
+ {
+ var literal = FacetChoice.Of("!important");
+
+ await Assert.That(literal.Token).IsEqualTo("!!important");
+ await Assert.That(FacetChoice.Parse(literal.Token)).IsEqualTo(literal);
+ await Assert.That(FacetChoice.Parse(literal.Token).Exclude).IsFalse();
+ }
+
+ ///
+ /// Polarity is applied to the answer, never per token.
+ ///
+ ///
+ /// A facet can hand one row several tokens — a game reached an hour ago is in the last day, the
+ /// last week and the last month. Inverting the comparison per token would make an excluded
+ /// selection mean "some token differs", which every multi-token row satisfies, so "not seen in
+ /// the last day" would return everything.
+ ///
+ [Test]
+ public async Task AMultiTokenFacetInvertsOnceRatherThanPerToken()
+ {
+ var choice = FacetChoice.Not("day");
+
+ await Assert.That(choice.Admits(covered: true)).IsFalse();
+ await Assert.That(choice.Admits(covered: false)).IsTrue();
+ await Assert.That(FacetChoice.Of("day").Admits(covered: true)).IsTrue();
+ }
+
+ /// The panel can tell the three states apart, which is what lets it draw them apart.
+ [Test]
+ public async Task AFacetValueReportsWhichOfTheThreeStatesItIsIn()
+ {
+ var listing = Search(new GameFilter { Codebase = FacetChoice.Not("Evennia") });
+ var codebase = listing.Facets.Single(f => f.Key == FacetKeys.Codebase);
+
+ await Assert.That(codebase.Values.Single(v => v.Token == "Evennia").State)
+ .IsEqualTo(FacetState.Excluded);
+ await Assert.That(codebase.Values.Single(v => v.Token == "ROM").State)
+ .IsEqualTo(FacetState.Unselected);
+ }
+
+ private static GameListing Search(GameFilter filter) =>
+ FacetedSearch.Search([.. new[] { Penn, Evennia, Rom, Nameless }.Select(Row)], filter);
+
+ private static GameFacetRow Row(GameSummary game) => new(
+ game,
+ ActivityBand.Quiet,
+ LastSeenBand.Week,
+ TlsMeasured: false,
+ Charset: null,
+ Language: null,
+ Codebase: game.Codebase,
+ Family: null,
+ Genre: null);
+
+ private static GameSummary Game(string slug, string? codebase) => new(
+ Guid.NewGuid(), slug, slug, null, LifecycleState.Active, IsClaimed: false,
+ PlayersNow: 1, Codebase: codebase, MeasuredProtocols: []);
+}
diff --git a/tests/MUI.Web.Tests/ReferenceFiguresTests.cs b/tests/MUI.Web.Tests/ReferenceFiguresTests.cs
index 8a12a3e..ad31192 100644
--- a/tests/MUI.Web.Tests/ReferenceFiguresTests.cs
+++ b/tests/MUI.Web.Tests/ReferenceFiguresTests.cs
@@ -121,7 +121,7 @@ public async Task TheCodebaseLinkAndTheCountAreOneFilter()
var queries = new FixtureGameQueries();
var figures = await CodebaseFigures.ReadAsync(queries, page.Codebase!);
- var listing = await queries.ListAsync(new GameFilter { CodebaseFamily = page.Codebase });
+ var listing = await queries.ListAsync(new GameFilter { CodebaseFamily = FacetChoice.Of(page.Codebase!) });
await Assert.That(figures.Listed).IsEqualTo(listing.Count);
await Assert.That(page.GamesPath).IsEqualTo("/games?codebase-family=Evennia");
@@ -144,7 +144,8 @@ public Task> ListAsync(
[
.. games
.Where(g => filter.IncludeArchived || g.State is not LifecycleState.Archived)
- .Where(g => CodebaseFamily.Matches(g.Codebase, filter.CodebaseFamily)),
+ .Where(g => filter.CodebaseFamily is not { } family
+ || family.Admits(CodebaseFamily.Matches(g.Codebase, family.Value))),
]);
public Task FindAsync(string slug, CancellationToken cancellationToken = default) =>