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>
This commit is contained in:
Meta Vens 2026-09-22 09:39:36 +02:00
commit 915262fee2
4 changed files with 349 additions and 45 deletions

View file

@ -197,7 +197,11 @@ namespace GameCult.Caching.Tests
Assert.That(leaf.TryGetIndexNumber(tiny, "tiny_double", out var tinyNumber), Is.True);
Assert.That(tinyNumber, Does.Not.Contain("E"));
Assert.That(tinyNumber, Is.EqualTo("0.0000001"));
// R-D (docs/cultnet-selection-cut.md): the exact decimal expansion, not a shortest
// round-trip form. 1e-7 is not exactly representable as a double; its nearest double's
// exact value is this - the old assertion pinned "0.0000001", the shortest form a
// pre-R-D render would have produced instead.
Assert.That(tinyNumber, Is.EqualTo("0.0000000999999999999999954748111825886258685613938723690807819366455078125"));
}
[Test]

View file

@ -32,6 +32,12 @@ namespace GameCult.Networking.Tests
[TestCase(" 1")]
[TestCase("1.")]
[TestCase("1..5")]
// R-D/Q-J (Soul's whole-cut pass, confirmed): .NET's $ matches immediately before a single
// trailing newline even without RegexOptions.Multiline, so a naive `$`-anchored pattern accepts
// "5\n" as canonical - the door must refuse it exactly as it refuses any other non-canonical
// spelling, byte for byte the same as Rust and the JSON schema's ECMA-262 $.
[TestCase("5\n")]
[TestCase("5\r\n")]
public void IsCanonicalRefusesNonCanonicalSpellings(string value) =>
Assert.That(CultNetCanonicalNumber.IsCanonical(value), Is.False);

View file

@ -0,0 +1,150 @@
#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();
}
}
}

View file

@ -1471,6 +1471,11 @@ namespace GameCult.Networking.Tests
}
}) { IsBackground = true };
serverThread.Start();
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
async Task FetchOnce(string messageId)
{
@ -1488,8 +1493,12 @@ namespace GameCult.Networking.Tests
await FetchOnce("first");
await WaitUntilAsync(() => server.Peers.Count == 0, TimeSpan.FromSeconds(2));
await FetchOnce("second");
cancellation.Cancel();
serverThread.Join();
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
// A batch is a value: nothing staged in it reaches CultNet observers before the store commits it. A put made
@ -1551,6 +1560,11 @@ namespace GameCult.Networking.Tests
}
}) { IsBackground = true };
serverThread.Start();
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
using var client = CultNetSchemaClients.CreateRudp("database-subscription-client");
var subscribed = new TaskCompletionSource<CultNetSnapshotResponseRawMessage>(
@ -1577,13 +1591,16 @@ namespace GameCult.Networking.Tests
Text = "live"
});
var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2));
cancellation.Cancel();
serverThread.Join();
Assert.That(update.SubscriptionId, Is.EqualTo("note"));
Assert.That(update.ChangeKind, Is.EqualTo("added"));
Assert.That(update.Document, Is.Not.Null);
Assert.That(update.Document!.RecordKey, Is.EqualTo(recordKey));
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
[Test]
@ -1611,6 +1628,11 @@ namespace GameCult.Networking.Tests
}
}) { IsBackground = true };
serverThread.Start();
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
await database.PutAsync(new CultRecordKey("tests:public:initial"), new NetworkSchemaNote
{
@ -1655,11 +1677,14 @@ namespace GameCult.Networking.Tests
Text = "public-live"
});
var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2));
cancellation.Cancel();
serverThread.Join();
Assert.That(update.Document, Is.Not.Null);
Assert.That(update.Document!.RecordKey, Is.EqualTo("tests:public:live"));
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
[Test]
@ -1698,6 +1723,11 @@ namespace GameCult.Networking.Tests
}
}) { IsBackground = true };
serverThread.Start();
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
var targetCache = new CultCache();
var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry)
@ -1717,6 +1747,12 @@ namespace GameCult.Networking.Tests
supportedBodyTransports: [CultMeshBodyTransportKind.SharedMemory.ToString()]),
TimeSpan.FromSeconds(2));
Assert.That(targetCache.Get(new CultRecordKey(recordKeyOne)), Is.Not.Null);
// R-K: PublishDemand runs after the snapshot is sent, on the server thread, while
// SubscribeAsync's completion is driven by the client's receipt of that same snapshot on a
// different thread - asserting immediately races the two. Wait for the deterministic
// condition (the demand actually being published) instead of assuming order-of-completion
// across a network round trip.
await WaitUntilAsync(() => bodyDemand.Plan(bodyId).HasConsumers, TimeSpan.FromSeconds(2));
Assert.That(bodyDemand.Plan(bodyId).HasConsumers, Is.True);
// Drive the record's identity change through the selection itself
@ -1744,6 +1780,8 @@ namespace GameCult.Networking.Tests
await WaitUntilAsync(
() => targetCache.Get(new CultRecordKey(recordKeyTwo)) == null,
TimeSpan.FromSeconds(2));
// R-K: same race as the HasConsumers=true assertion above, the other direction.
await WaitUntilAsync(() => !bodyDemand.Plan(bodyId).HasConsumers, TimeSpan.FromSeconds(2));
Assert.That(bodyDemand.Plan(bodyId).HasConsumers, Is.False);
var changeCountAfterRevocation = changes.Count;
await sourceDatabase.PutAsync(new CultRecordKey(recordKeyTwo), new NetworkSchemaNote
@ -1752,11 +1790,14 @@ namespace GameCult.Networking.Tests
Text = "must-not-escape"
});
await Task.Delay(100);
cancellation.Cancel();
serverThread.Join();
Assert.That(changes.Count, Is.EqualTo(changeCountAfterRevocation));
Assert.That(targetCache.Get(new CultRecordKey(recordKeyTwo)), Is.Null);
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
// Reconcile's remaining responsibility after the S2-2 deletion (docs/cultnet-selection-cut.md,
@ -1874,6 +1915,11 @@ namespace GameCult.Networking.Tests
}
}) { IsBackground = true };
serverThread.Start();
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
using var client = CultNetSchemaClients.CreateRudp("database-body-demand-client");
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
@ -1904,10 +1950,14 @@ namespace GameCult.Networking.Tests
SubscriptionId = "world-body"
});
var removed = await AwaitWithTimeout(withdrawn.Task, TimeSpan.FromSeconds(2));
cancellation.Cancel();
serverThread.Join();
Assert.That(removed.ConsumerRuntimeId, Is.EqualTo("eve-unity"));
Assert.That(removed.Active, Is.False);
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
// A load's delivery reaches a subscription observer that needs the lifecycle gate while a subscribe's demand
@ -1958,6 +2008,11 @@ namespace GameCult.Networking.Tests
}
}) { IsBackground = true };
serverThread.Start();
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
using var client = CultNetSchemaClients.CreateRudp("demand-write-client");
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
@ -1974,7 +2029,14 @@ namespace GameCult.Networking.Tests
cancellation.Cancel();
Assert.That(finished, Is.True, "the demand handler's cache write deadlocked with the load's delivery");
await written.Task;
serverThread.Join();
}
finally
{
// Cancel/Join before disposing anything the poll thread touches (server), so a failed
// assertion above cannot race the thread against a disposed socket (R-K).
cancellation.Cancel();
serverThread.Join();
}
subscriptions.Dispose();
server.Dispose();
database.Dispose();
@ -2038,6 +2100,11 @@ namespace GameCult.Networking.Tests
}
}) { IsBackground = true };
serverThread.Start();
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
using var client = CultNetSchemaClients.CreateRudp("database-reactive-demand-client");
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
@ -2053,11 +2120,15 @@ namespace GameCult.Networking.Tests
});
var observed = await AwaitWithTimeout(active.Task, TimeSpan.FromSeconds(2));
cancellation.Cancel();
serverThread.Join();
Assert.That(observed.RecordKeys, Is.EqualTo(new[] { "world:field:fog" }));
Assert.That(observed.SchemaIds, Is.EqualTo(new[] { "gamecult.fields.splats.v1" }));
Assert.That(observed.BodyIds, Is.Empty);
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
[Test]
@ -2089,6 +2160,11 @@ namespace GameCult.Networking.Tests
}
}) { IsBackground = true };
serverThread.Start();
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
using var client = CultNetSchemaClients.CreateRudp("database-disconnect-demand-client");
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
@ -2107,11 +2183,14 @@ namespace GameCult.Networking.Tests
client.Dispose();
var removed = await AwaitWithTimeout(withdrawn.Task, TimeSpan.FromSeconds(2));
await WaitUntilAsync(() => server.Peers.Count == 0, TimeSpan.FromSeconds(2));
cancellation.Cancel();
serverThread.Join();
Assert.That(removed.SubscriptionId, Is.EqualTo("disconnect-world-body"));
Assert.That(removed.Active, Is.False);
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
[Test]
@ -2141,6 +2220,11 @@ namespace GameCult.Networking.Tests
}
}) { IsBackground = true };
serverThread.Start();
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
var targetCache = new CultCache();
var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry)
@ -2164,12 +2248,15 @@ namespace GameCult.Networking.Tests
Text = "live"
});
var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2));
cancellation.Cancel();
serverThread.Join();
Assert.That(update.SubscriptionId, Is.EqualTo("notes"));
Assert.That(update.Document, Is.TypeOf<NetworkSchemaNote>());
Assert.That(((NetworkSchemaNote)targetCache.Get(new CultRecordKey(recordKey))!).Text, Is.EqualTo("live"));
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
[Test]
@ -2199,6 +2286,11 @@ namespace GameCult.Networking.Tests
}
}) { IsBackground = true };
serverThread.Start();
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
var targetCache = new CultCache();
var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry)
@ -2225,11 +2317,14 @@ namespace GameCult.Networking.Tests
Text = "live"
});
var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2));
cancellation.Cancel();
serverThread.Join();
Assert.That(((NetworkSchemaNote)update.Document!).Text, Is.EqualTo("live"));
Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.Null);
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
[Test]
@ -2254,6 +2349,11 @@ namespace GameCult.Networking.Tests
}
}) { IsBackground = true };
serverThread.Start();
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
var targetCache = new CultCache();
var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry)
@ -2290,11 +2390,14 @@ namespace GameCult.Networking.Tests
Text = "updated"
});
update = await AwaitWithTimeout(updated.Task, TimeSpan.FromSeconds(2));
cancellation.Cancel();
serverThread.Join();
Assert.That(update.Text, Is.EqualTo("updated"));
Assert.That(value.Current.Text, Is.EqualTo("updated"));
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
[Test]
@ -2328,6 +2431,11 @@ namespace GameCult.Networking.Tests
}
}) { IsBackground = true };
serverThread.Start();
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
var targetCache = new CultCache();
var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry)
@ -2341,11 +2449,14 @@ namespace GameCult.Networking.Tests
client.SubscribeLiveValueAsync<NetworkSchemaNote>("demanded-note", recordKey),
TimeSpan.FromSeconds(2));
await WaitUntilAsync(() => value.HasValue, TimeSpan.FromSeconds(2));
cancellation.Cancel();
serverThread.Join();
Assert.That(value.Current.Text, Is.EqualTo("materialized-on-demand"));
Assert.That(targetCache.Get(new CultRecordKey(recordKey)), Is.Null);
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
[Test]
@ -2376,6 +2487,11 @@ namespace GameCult.Networking.Tests
}
}) { IsBackground = true };
serverThread.Start();
// R-K: the join moves into finally - a failed assertion below must not leave this poll
// thread running past the point where `using` disposes the socket it polls, which turns a
// test failure into an unhandled-exception host crash.
try
{
var targetCache = new CultCache();
var targetDocuments = new CultNetDocumentRegistry(targetCache.Registry)
@ -2396,11 +2512,14 @@ namespace GameCult.Networking.Tests
Text = "wire-live"
});
var update = await AwaitWithTimeout(changed.Task, TimeSpan.FromSeconds(2));
cancellation.Cancel();
serverThread.Join();
Assert.That(update.SchemaId, Is.EqualTo(wireSchema));
Assert.That(((NetworkSchemaNote)update.Document!).Text, Is.EqualTo("wire-live"));
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
[Test]
@ -2430,16 +2549,29 @@ namespace GameCult.Networking.Tests
var method = typeof(CultNetDatabaseSubscriptionServer).GetMethod(
"CreateMatchedRecord",
BindingFlags.Instance | BindingFlags.NonPublic)!;
var requestType = typeof(CultNetDatabaseSubscriptionServer).GetNestedType(
"SubscriptionRequest", BindingFlags.NonPublic)!;
var requestCtor = requestType.GetConstructors(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public).Single();
var request = requestCtor.Invoke(new object?[]
{
new CultNetSelection
{
Schemas = ["tests.networking_note.v1"],
Keys = ["tests:subscription-client:alias-note"]
},
"alias-notes",
"alias-notes",
true,
null,
null,
null,
false
});
var record = (CultNetRawDocumentRecord?)method.Invoke(subscriptions, new object?[]
{
change,
new CultNetDatabaseSubscribeMessage
{
SubscriptionId = "alias-notes",
SchemaIds = ["tests.networking_note.v1"],
RecordKeys = ["tests:subscription-client:alias-note"]
},
request,
null
});
@ -2494,6 +2626,8 @@ namespace GameCult.Networking.Tests
catch (Exception error) { serverFailure.TrySetResult(error); }
}) { IsBackground = true };
serverThread.Start();
try
{
using var client = CultNetSchemaClients.CreateRudp(
runtimeId: "csharp-rudp-large-snapshot-client",
@ -2516,12 +2650,16 @@ namespace GameCult.Networking.Tests
await WaitUntilAsync(
() => server.Peers.Count == 1 && server.Peers.Single().PendingReliablePacketCount == 0,
TimeSpan.FromSeconds(2));
cancellation.Cancel();
serverThread.Join();
Assert.That(response.Documents, Has.Length.EqualTo(1));
Assert.That(response.Documents[0].Payload, Has.Length.EqualTo(128 * 1024));
Assert.That(server.Stats.BytesSent, Is.LessThan(512 * 1024),
"A lossless large response must not remain trapped in the reliable resend queue.");
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
[Test]
@ -2555,6 +2693,8 @@ namespace GameCult.Networking.Tests
catch (Exception error) { serverFailure.TrySetResult(error); }
}) { IsBackground = true };
serverThread.Start();
try
{
using var client = CultNetSchemaClients.CreateRudp(
runtimeId: "csharp-rudp-concurrent-publish-client",
@ -2597,8 +2737,12 @@ namespace GameCult.Networking.Tests
if (completed == serverFailure.Task) throw await serverFailure.Task;
if (completed != receivedAll.Task)
Assert.Fail($"Concurrent publish timed out after receiving {receivedIds.Count} of {expectedMessages} logical messages.");
cancellation.Cancel();
serverThread.Join();
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
Assert.That(receivedIds.Count, Is.EqualTo(expectedMessages));
}