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:
parent
790385364c
commit
915262fee2
4 changed files with 349 additions and 45 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
150
tests/GameCult.Networking.Tests/CultNetSelectionPageTests.cs
Normal file
150
tests/GameCult.Networking.Tests/CultNetSelectionPageTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue