using System.Collections.Concurrent; using System.Text.Json; using DorkNet.Server.Hubs; using Microsoft.AspNetCore.SignalR; namespace DorkNet.Server.Tests; /// /// Stand-in for the notify hub that records every push instead of sending it, /// so tests can assert on the exact frames the game client would receive. /// A frame is the hub method plus its single string argument, parsed back into /// the {Id, Msg} envelope. /// public sealed class CapturingHubContext : IHubContext { private readonly ConcurrentQueue _sent = new(); public IHubClients Clients => new CapturingClients(this); public IGroupManager Groups { get; } = new NoopGroups(); public IReadOnlyList Sent => _sent.ToArray(); /// Frames sent to a target such as group:player:12, in /// order, optionally filtered by envelope Id. public IReadOnlyList To(string target, string? id = null) => _sent.Where(f => f.Target == target && (id is null || f.Id == id)).ToList(); public static string PlayerGroup(long playerId) => $"group:{NotifyHub.GroupForPlayer(playerId)}"; internal void Record(string target, IReadOnlyList? excluded, string method, object?[] args) => _sent.Enqueue(new SentFrame(target, excluded ?? Array.Empty(), method, args.Length == 1 && args[0] is string s ? s : JsonSerializer.Serialize(args))); public sealed record SentFrame(string Target, IReadOnlyList Excluded, string Method, string Payload) { public JsonElement Envelope => JsonDocument.Parse(Payload).RootElement; public string? Id => Envelope.TryGetProperty("Id", out var id) ? id.GetString() : null; public JsonElement Msg => Envelope.GetProperty("Msg"); } private sealed class CapturingClients(CapturingHubContext owner) : IHubClients { private IClientProxy Proxy(string target, IReadOnlyList? excluded = null) => new CapturingProxy(owner, target, excluded); public IClientProxy All => Proxy("all"); public IClientProxy AllExcept(IReadOnlyList excludedConnectionIds) => Proxy("all", excludedConnectionIds); public IClientProxy Client(string connectionId) => Proxy($"conn:{connectionId}"); public IClientProxy Clients(IReadOnlyList connectionIds) => Proxy($"conns:{string.Join(",", connectionIds)}"); public IClientProxy Group(string groupName) => Proxy($"group:{groupName}"); public IClientProxy GroupExcept(string groupName, IReadOnlyList excludedConnectionIds) => Proxy($"group:{groupName}", excludedConnectionIds); public IClientProxy Groups(IReadOnlyList groupNames) => new FanOutProxy(groupNames.Select(g => Proxy($"group:{g}")).ToList()); public IClientProxy User(string userId) => Proxy($"user:{userId}"); public IClientProxy Users(IReadOnlyList userIds) => Proxy($"users:{string.Join(",", userIds)}"); } private sealed class CapturingProxy(CapturingHubContext owner, string target, IReadOnlyList? excluded) : IClientProxy { public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) { owner.Record(target, excluded, method, args); return Task.CompletedTask; } } private sealed class FanOutProxy(IReadOnlyList proxies) : IClientProxy { public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default) => Task.WhenAll(proxies.Select(p => p.SendCoreAsync(method, args, cancellationToken))); } private sealed class NoopGroups : IGroupManager { public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default) => Task.CompletedTask; } }