CultLib/tests/GameCult.Networking.Tests/CultNetDocumentRegistryVariantTests.cs
Meta Vens 5d6847e220 Align the C1 tests with the unique-index rule and fix the live-subscription fixture
Hand-written and directory-store variants now override their indexed value or
name as the rules require; the subscription test writes its variant through a
committed batch so the file holds it, subscribes by key, and reports what it
saw when it times out.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 07:04:01 +02:00

209 lines
10 KiB
C#

#nullable enable
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
using MessagePack;
using NUnit.Framework;
namespace GameCult.Networking.Tests
{
// Q6 (docs/document-variants-cut.md C1): CultNet carries no variant deltas yet, so building a row for one is refused
// loudly, on the v1 selection path and on the v0 snapshot path alike.
public sealed class CultNetDocumentRegistryVariantTests
{
private static readonly CultRecordKey BaseKey = new("variant-fixture-base");
private static readonly CultRecordKey VariantKey = new("variant-fixture-big");
private static async Task<(CultCache Cache, CultNetDocumentRegistry Documents)> BuildFixtureAsync(bool withVariant)
{
var registry = CultDocumentRegistry.Shared;
var cache = new CultCache(registry, CultCacheMessagePack.CreateCodec(registry));
var documents = new CultNetDocumentRegistry(registry)
.Register(CultNetDocumentBinding.ForDocument<VariantWireFixture>(registry));
await cache.UpsertAsync(new VariantWireFixture { Name = "base", Power = 1 }, new CultRecordHandle<VariantWireFixture>(BaseKey));
if (withVariant)
await cache.UpsertVariantAsync(VariantKey, BaseKey, new[]
{
cache.Override<VariantWireFixture>(nameof(VariantWireFixture.Name), "big"),
cache.Override<VariantWireFixture>(nameof(VariantWireFixture.Power), 9)
});
return (cache, documents);
}
[Test]
public async Task SelectionResponseRefusesAVariantRowNamingIt()
{
var (cache, documents) = await BuildFixtureAsync(withVariant: true);
var selection = new CultNetSelection { Projection = CultNetSelectionProjections.Document };
var error = Assert.Throws<NotSupportedException>(() =>
documents.CreateSelectionResponse(cache, "m1", selection, ordinalOf: (_, _) => 1, asOf: 1))!;
Assert.That(error.Message, Does.Contain(VariantKey.Value).And.Contain(BaseKey.Value));
}
[Test]
public async Task RawSnapshotResponseRefusesAVariantRowNamingIt()
{
var (cache, documents) = await BuildFixtureAsync(withVariant: true);
var error = Assert.Throws<NotSupportedException>(() => documents.CreateRawSnapshotResponse(cache, "m1"))!;
Assert.That(error.Message, Does.Contain(VariantKey.Value));
}
[Test]
public async Task ACacheWithoutVariantsStillAnswersBothPaths()
{
var (cache, documents) = await BuildFixtureAsync(withVariant: false);
var selection = new CultNetSelection { Projection = CultNetSelectionProjections.Document };
Assert.That(documents.CreateSelectionResponse(cache, "m1", selection, ordinalOf: (_, _) => 1, asOf: 1).Documents, Has.Length.EqualTo(1));
Assert.That(documents.CreateRawSnapshotResponse(cache, "m2").Documents, Has.Length.EqualTo(1));
}
// The live-change funnel: a load that lands a variant reaches a subscriber as neither a record nor silence.
[Test]
public async Task ALoadThatLandsAVariantNeverReachesASubscriberAsARecordAndTheSubscriberIsToldByKey()
{
var registry = CultDocumentRegistry.Shared;
var directory = Path.Combine(Path.GetTempPath(), $"cultlib-variant-live-{Guid.NewGuid():N}");
Directory.CreateDirectory(directory);
try
{
var path = Path.Combine(directory, "wire.cc");
using (var writer = CultCacheMessagePack.Create(path, new CultCacheOpenOptions { Registry = registry }))
{
writer.Commit(batch => batch.Upsert(typeof(VariantWireFixture), new VariantWireFixture { Name = "base", Power = 1 }, BaseKey));
writer.Commit(batch => batch.UpsertVariant(VariantKey, BaseKey, new[]
{
writer.Override<VariantWireFixture>(nameof(VariantWireFixture.Name), "big"),
writer.Override<VariantWireFixture>(nameof(VariantWireFixture.Power), 9)
}));
}
var cache = new CultCache(registry, CultCacheMessagePack.CreateCodec(registry));
var documents = new CultNetDocumentRegistry(registry)
.Register(CultNetDocumentBinding.ForDocument<VariantWireFixture>(registry));
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = documents });
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
socket.Bind(new IPEndPoint(IPAddress.Loopback, 0));
socket.ReceiveTimeout = 20;
using var server = new RudpCultNetSchemaServer(new RudpCultNetSchemaServerOptions
{
RuntimeId = "variant-live-server",
Socket = socket
});
using var subscriptions = new CultNetDatabaseSubscriptionServer(server, database);
using var cancellation = new CancellationTokenSource();
var serverThread = new Thread(() =>
{
while (!cancellation.IsCancellationRequested)
{
_ = server.PollOnceAsync().GetAwaiter().GetResult();
Thread.Sleep(1);
}
}) { IsBackground = true };
serverThread.Start();
try
{
using var client = CultNetSchemaClients.CreateRudp("variant-live-client");
var snapshot = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var changes = new ConcurrentQueue<CultNetDatabaseChangeRawMessage>();
var errors = new ConcurrentQueue<CultNetErrorMessage>();
client.OnCultNet<CultNetSnapshotResponseRawMessage>(_ => snapshot.TrySetResult(true));
client.OnCultNet<CultNetDatabaseChangeRawMessage>(changes.Enqueue);
client.OnCultNet<CultNetErrorMessage>(errors.Enqueue);
client.Connect("127.0.0.1", server.LocalEndPoint.Port);
await WaitUntilAsync(() => client.Connected);
client.SendCultNet(new CultNetDatabaseSubscribeMessage
{
MessageId = "subscribe-all",
SubscriptionId = "all",
RecordKeys = new[] { BaseKey.Value, VariantKey.Value },
IncludeSnapshot = true
});
await WaitUntilAsync(() => snapshot.Task.IsCompleted);
cache.AddBackingStore(new SingleFileMessagePackBackingStore(path));
await WaitUntilAsync(() => !errors.IsEmpty || changes.Any(change => change.Document?.RecordKey == VariantKey.Value),
() => $"changes={string.Join(",", changes.Select(c => c.ChangeKind + ":" + c.Document?.RecordKey))} errors={string.Join(",", errors.Select(e => e.Error))} cacheKeys={cache.GetStored(BaseKey) != null}/{cache.GetStored(VariantKey) != null}");
await Task.Delay(200); // let anything else already in flight arrive
Assert.That(changes.Select(change => change.Document?.RecordKey), Does.Not.Contain(VariantKey.Value),
"a variant must not reach a subscriber as a plain record");
Assert.That(errors.Select(error => error.Error), Has.Some.Contains(VariantKey.Value),
"the subscriber is told, naming the variant");
}
finally
{
cancellation.Cancel();
serverThread.Join();
}
}
finally
{
Directory.Delete(directory, recursive: true);
}
}
[Test]
public async Task TheDatabaseServersLiveChangeForAVariantIsRefusedNamingIt()
{
var (cache, documents) = await BuildFixtureAsync(withVariant: true);
var database = new CultNetDatabase(cache, new CultNetDatabaseOptions { DocumentRegistry = documents });
using var server = new Server(cache, ServerSecurityOptions.Development());
using var databaseServer = new CultNetDatabaseServer(server, database);
var schemaId = cache.Registry.GetRequired<VariantWireFixture>().SchemaId;
var change = new CultNetDatabaseChange<VariantWireFixture>(
CultNetDatabaseChangeKind.Added,
VariantKey,
schemaId,
database.Shards[0],
cache.Get<VariantWireFixture>(VariantKey)!,
previousDocument: null);
var method = typeof(CultNetDatabaseServer).GetMethod("CreateChangeMessage", BindingFlags.Instance | BindingFlags.NonPublic)!;
var refused = Assert.Throws<TargetInvocationException>(() => method.Invoke(databaseServer, new object[]
{
change, "sub-1", new CultNetSelection { Projection = CultNetSelectionProjections.Document }
}))!;
Assert.That(refused.InnerException, Is.TypeOf<NotSupportedException>());
Assert.That(refused.InnerException!.Message, Does.Contain(VariantKey.Value).And.Contain(BaseKey.Value));
}
private static async Task WaitUntilAsync(Func<bool> condition, Func<string>? diagnostic = null)
{
var deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5);
while (!condition())
{
if (DateTimeOffset.UtcNow > deadline)
Assert.Fail("Condition was not satisfied before timeout. " + diagnostic?.Invoke());
await Task.Delay(5);
}
}
[CultDocument("variants.wire-fixture", "variants.wire-fixture.v1")]
[MessagePackObject(AllowPrivate = true)]
public sealed class VariantWireFixture
{
[Key(0)]
[CultName]
public string Name = string.Empty;
[Key(1)]
public int Power;
}
}
}