CultLib/tests/GameCult.Networking.Tests/CultNetSelectionPageTests.cs
Meta Vens 915262fee2 CultNet selection cut1 fix batch: R-I/K/D tests and R-A/R-M reflection fix
- R-I: new CultNetSelectionPageTests.cs kills Soul's two named S20
  mutants - dropping `wantDocument &&` in ToEdge (a header-projection
  edge would carry document payload bytes anyway) and always emitting
  Edges instead of `selection.HasHop ? ... : null` (a hop-less
  selection's page would carry an empty array instead of no Edges
  member) - plus a matched-vs-page-length test for R-G. Fixing
  ToEdge's mutant coverage surfaced a real bug the new tests caught:
  it serialized an edge payload through CultDocumentMessagePackSerialization,
  which requires the payload's type to be a registered CultDocument
  and throws for a plain declared value type (float, in the fixture);
  it now serializes directly through MessagePackSerializer.
- R-D: CultNetCanonicalNumberTests gains "5\n"/"5\r\n" cases - .NET's
  $ matches immediately before a single trailing newline even without
  RegexOptions.Multiline, so the door's pattern is re-anchored with \A/\z
  and would have accepted "5\n" as canonical without this. Caching's
  TryGetIndexNumberRewritesADoublesExponentNotationToPositionalDigits
  is updated for 1e-7's actual exact decimal expansion (the R-D commit's
  own effect): the old assertion pinned "0.0000001", the shortest
  round-trip form a pre-R-D render would have produced.
- R-K: every serverThread.Join() in NetworkingTests.cs that sat outside
  a try/finally now sits inside one, so a failed assertion cannot leave
  the poll thread running past the point where `using` disposes the
  socket it polls (an ObjectDisposedException on that raw background
  Thread was a host crash, not a test failure). The bodyDemand.HasConsumers
  assertions wait on the condition instead of assuming
  SubscribeAsync's completion and PublishDemand happened in an order
  a network round trip does not guarantee.
- Reflection fix: DatabaseSubscription_FiltersCanonicalWireChangesByRequestedSchemaAlias
  built a v0-shaped CultNetDatabaseSubscribeMessage to invoke
  CreateMatchedRecord by reflection; that method now takes the shared
  SubscriptionRequest (R-A), so the test builds one through the same
  reflection path instead.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-22 09:39:36 +02:00

150 lines
7.5 KiB
C#

#nullable enable
using System.Collections.Generic;
using System.Threading.Tasks;
using GameCult.Caching;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Networking.Tests
{
// R-I (docs/cultnet-selection-cut.md): SelectPage/CreateSelectionResponse had no test at all before
// this cut's fix batch, and Soul's whole-cut pass found two untested mutants survive 196/196:
// dropping `wantDocument &&` in ToEdge (a header-projection edge would then carry the document's
// payload bytes anyway), and always emitting Edges instead of `selection.HasHop ? ... : null` (a
// hop-less selection's page would then carry an empty Edges array instead of no Edges member).
public sealed class CultNetSelectionPageTests
{
private static (CultCache cache, CultNetDocumentRegistry documents) BuildFixture()
{
var cache = new CultCache();
var documents = new CultNetDocumentRegistry(cache.Registry)
.Register(CultNetDocumentBinding.ForDocument<PageFixtureLeaf>(cache.Registry))
.Register(CultNetDocumentBinding.ForDocument<PageFixtureCiter>(cache.Registry));
return (cache, documents);
}
private static async Task<CultRecordKey> AddLeafAsync(CultCache cache, string key, string kind)
{
var recordKey = new CultRecordKey(key);
await cache.AddAsync(
new PageFixtureLeaf { Name = key, Kind = kind },
new CultRecordHandle<PageFixtureLeaf>(recordKey));
return recordKey;
}
// Kills Soul's ToEdge mutant (CultNetDocumentRegistry.cs, ~:468): dropping `wantDocument &&`
// from `if (wantDocument && edge.Payload != null)` would leak the dictionary edge's payload
// bytes into a header-projection page, which carries no payloads by contract.
[Test]
public async Task CreateSelectionResponse_UnderHeaderProjection_CarriesNoEdgePayload()
{
var (cache, documents) = BuildFixture();
var target = await AddLeafAsync(cache, "leaf-target", "weapon");
await cache.AddAsync(
new PageFixtureCiter { Name = "citer-1", Components = new Dictionary<CultRecordRef<PageFixtureLeaf>, float> { [new CultRecordRef<PageFixtureLeaf>(target)] = 2.5f } },
new CultRecordHandle<PageFixtureCiter>(new CultRecordKey("citer-1")));
var selection = new CultNetSelection
{
Cited = new CultNetIncoming { Role = "Components", Exists = true },
Projection = CultNetSelectionProjections.Header
};
var response = documents.CreateSelectionResponse(cache, "m1", selection, ordinalOf: (_, _) => 1, asOf: 1);
Assert.That(response.Edges, Is.Not.Null.And.Not.Empty, "a hop-bearing selection with a match must carry the edge it traversed");
foreach (var edge in response.Edges!)
{
Assert.That(edge.PayloadEncoding, Is.Null, "header projection must not carry an edge's payload encoding");
Assert.That(edge.Payload, Is.Null, "header projection must not carry an edge's payload bytes");
}
}
// The positive case for the same rule: document projection does carry the payload.
[Test]
public async Task CreateSelectionResponse_UnderDocumentProjection_CarriesEdgePayload()
{
var (cache, documents) = BuildFixture();
var target = await AddLeafAsync(cache, "leaf-target", "weapon");
await cache.AddAsync(
new PageFixtureCiter { Name = "citer-1", Components = new Dictionary<CultRecordRef<PageFixtureLeaf>, float> { [new CultRecordRef<PageFixtureLeaf>(target)] = 2.5f } },
new CultRecordHandle<PageFixtureCiter>(new CultRecordKey("citer-1")));
var selection = new CultNetSelection
{
Cited = new CultNetIncoming { Role = "Components", Exists = true },
Projection = CultNetSelectionProjections.Document
};
var response = documents.CreateSelectionResponse(cache, "m1", selection, ordinalOf: (_, _) => 1, asOf: 1);
Assert.That(response.Edges, Is.Not.Null.And.Not.Empty);
Assert.That(response.Edges![0].PayloadEncoding, Is.EqualTo("messagepack"));
Assert.That(response.Edges![0].Payload, Is.Not.Null.And.Not.Empty);
}
// Kills Soul's SelectPage/CreateSelectionResponse mutant (CultNetDocumentRegistry.cs, ~:378):
// always emitting `Edges = ...` instead of `selection.HasHop ? ... : null` would give a
// hop-less selection's page an empty Edges array (Is.Empty) instead of no Edges member at all
// (Is.Null) - a mutant a shape-only "no edges reported" assertion cannot tell apart from the rule.
[Test]
public async Task CreateSelectionResponse_WithoutAHop_CarriesNoEdgesMember()
{
var (cache, documents) = BuildFixture();
await AddLeafAsync(cache, "leaf-only", "weapon");
var selection = new CultNetSelection { Projection = CultNetSelectionProjections.Document };
var response = documents.CreateSelectionResponse(cache, "m1", selection, ordinalOf: (_, _) => 1, asOf: 1);
Assert.That(response.Documents, Has.Length.EqualTo(1));
Assert.That(response.Edges, Is.Null);
}
// SelectPage/CreateSelectionResponse's own paging and matched-total contract (R-G/C5): a
// limited page reports the whole matching count, not the page's row count, and carries a
// cursor while more rows remain.
[Test]
public async Task CreateSelectionResponse_ReportsTotalMatchedNotPageLength()
{
var (cache, documents) = BuildFixture();
await AddLeafAsync(cache, "leaf-1", "weapon");
await AddLeafAsync(cache, "leaf-2", "weapon");
await AddLeafAsync(cache, "leaf-3", "weapon");
var selection = new CultNetSelection
{
Fields = new[] { new CultNetFieldPredicate { Index = "kind", Op = "any_of", Values = new[] { "weapon" } } },
Limit = 2
};
var response = documents.CreateSelectionResponse(cache, "m1", selection, ordinalOf: static (_, _) => 1, asOf: 1);
Assert.That(response.Headers, Has.Length.EqualTo(2), "the page itself is capped at the limit");
Assert.That(response.Matched, Is.EqualTo(3), "matched is the selection's whole matching count, not this page's length");
Assert.That(response.Next, Is.Not.Null.And.Not.Empty, "a cursor is minted while more rows remain");
}
[CultDocument("selection.page-fixture.leaf", "selection.page-fixture.leaf.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class PageFixtureLeaf
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
[CultIndex("kind")]
public string Kind = string.Empty;
}
[CultDocument("selection.page-fixture.citer", "selection.page-fixture.citer.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class PageFixtureCiter
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
[CultReference(typeof(PageFixtureLeaf), many: true)]
public Dictionary<CultRecordRef<PageFixtureLeaf>, float> Components = new();
}
}
}