using System.Runtime.CompilerServices; using AcDream.App.Net; using AcDream.App.UI; using AcDream.Core.Chat; using AcDream.Core.Social; using AcDream.UI.Abstractions; namespace AcDream.App.Tests.Net; public sealed class LiveSessionCommandRouterTests { [Fact] public void InactiveAndDisposedRouter_CannotReachTransport() { var sent = new List(); var router = NewRouter(sendTalk: sent.Add); router.Publish(new SendServerCommandCmd("@before")); router.Activate(); router.Activate(); router.Publish(new SendServerCommandCmd("@active")); router.Dispose(); router.Dispose(); router.Publish(new SendServerCommandCmd("@after")); Assert.Equal(["@active"], sent); Assert.False(router.IsActive); } [Fact] public void TellAndLegacyChannel_PreserveOutboundAndEchoPolicy() { var tells = new List<(string Target, string Text)>(); var channels = new List<(uint Id, string Text)>(); var chat = new ChatLog(); var router = NewRouter( chat: chat, sendTell: (target, text) => tells.Add((target, text)), sendChannel: (id, text) => channels.Add((id, text))); router.Activate(); router.Publish(new SendChatCmd(ChatChannelKind.Tell, "Friend", "hello")); router.Publish(new SendChatCmd(ChatChannelKind.Fellowship, null, "group")); Assert.Equal([("Friend", "hello")], tells); Assert.Equal([(0x00000800u, "group")], channels); Assert.Collection( chat.Snapshot(), entry => { Assert.Equal(ChatKind.Tell, entry.Kind); Assert.Equal("Friend", entry.Sender); }, entry => { Assert.Equal(ChatKind.Channel, entry.Kind); Assert.Equal("Fellowship", entry.ChannelName); }); } [Fact] public void TurbineChannel_UsesRuntimeRoomCookieAndNoOptimisticEcho() { var turbine = new TurbineChatState(); turbine.OnChannelsReceived( allegianceRoom: 0u, generalRoom: 0x70000001u, tradeRoom: 0u, lfgRoom: 0u, roleplayRoom: 0u, olthoiRoom: 0u, societyRoom: 0u, societyCelestialHandRoom: 0u, societyEldrytchWebRoom: 0u, societyRadiantBloodRoom: 0u); var sent = new List<(uint Room, uint Type, uint Dispatch, uint Sender, string Text, uint Cookie)>(); var chat = new ChatLog(); var router = NewRouter( chat: chat, turbine: turbine, playerGuid: () => 0x50000001u, sendTurbine: (room, type, dispatch, sender, text, cookie) => sent.Add((room, type, dispatch, sender, text, cookie))); router.Activate(); router.Publish(new SendChatCmd(ChatChannelKind.General, null, "world")); Assert.Collection(sent, message => { Assert.Equal(0x70000001u, message.Room); Assert.Equal(0x02u, message.Type); Assert.Equal(0x02u, message.Dispatch); Assert.Equal(0x50000001u, message.Sender); Assert.Equal("world", message.Text); Assert.Equal(1u, message.Cookie); }); Assert.Equal(0, chat.Count); } [Fact] public void ActivateAfterDispose_IsRejected() { var router = NewRouter(); router.Dispose(); Assert.Throws(router.Activate); } [Fact] public async Task ConcurrentDispose_WaitsForInFlightTransportThenMakesRouterInert() { using var sendEntered = new ManualResetEventSlim(); using var releaseSend = new ManualResetEventSlim(); var sent = new List(); var router = NewRouter(sendTalk: text => { sendEntered.Set(); releaseSend.Wait(); sent.Add(text); }); router.Activate(); Task publish = Task.Run(() => router.Publish(new SendServerCommandCmd("@active"))); Assert.True(sendEntered.Wait(TimeSpan.FromSeconds(5))); using var disposeStarted = new ManualResetEventSlim(); Task dispose = Task.Run(() => { disposeStarted.Set(); router.Dispose(); }); Assert.True(disposeStarted.Wait(TimeSpan.FromSeconds(5))); Task firstCompletion = await Task.WhenAny( dispose, Task.Delay(TimeSpan.FromMilliseconds(100))); Assert.NotSame(dispose, firstCompletion); releaseSend.Set(); await Task.WhenAll(publish, dispose); router.Publish(new SendServerCommandCmd("@after")); Assert.Equal(["@active"], sent); Assert.False(router.IsActive); } [Fact] public void ReentrantDisposeFromLog_PreventsLaterTransportAndEcho() { var turbine = new TurbineChatState(); turbine.OnChannelsReceived( 0u, 0x70000001u, 0u, 0u, 0u, 0u, 0u, 0u, 0u, 0u); var sent = new List(); var chat = new ChatLog(); LiveSessionCommandRouter? router = null; router = NewRouter( chat: chat, turbine: turbine, sendTurbine: (_, _, _, _, text, _) => sent.Add(text), log: _ => router!.Dispose()); router.Activate(); router.Publish(new SendChatCmd(ChatChannelKind.General, null, "world")); Assert.Empty(sent); Assert.Equal(0, chat.Count); Assert.False(router.IsActive); } [Fact] public void RetainedDisposedRouter_ReleasesCapturedTransportGraph() { (LiveSessionCommandRouter router, WeakReference transport) = CreateRouterWithCapturedTransport(); router.Dispose(); ForceFullCollection(); Assert.False(transport.TryGetTarget(out _)); GC.KeepAlive(router); } [Fact] public void DelayedConfirmationCallback_ReleasesTransportAndBecomesInert() { (LiveSessionCommandRouter router, WeakReference transport, Action callback) = CreateRouterWithDelayedConfirmation(); router.Dispose(); ForceFullCollection(); Assert.False(transport.TryGetTarget(out _)); callback(true); Assert.False(router.IsActive); GC.KeepAlive(callback); GC.KeepAlive(router); } private static LiveSessionCommandRouter NewRouter( ChatLog? chat = null, TurbineChatState? turbine = null, Func? playerGuid = null, Action? sendTalk = null, Action? sendTell = null, Action? sendChannel = null, Action? sendTurbine = null, Action? log = null, ClientCommandController.Bindings? clientBindings = null) => new( new LiveSessionCommandBindings( clientBindings ?? NewClientBindings(), chat ?? new ChatLog(), turbine ?? new TurbineChatState(), playerGuid ?? (() => 0u), sendTalk ?? (_ => { }), sendTell ?? ((_, _) => { }), sendChannel ?? ((_, _) => { }), sendTurbine ?? ((_, _, _, _, _, _) => { }), log)); [MethodImpl(MethodImplOptions.NoInlining)] private static (LiveSessionCommandRouter, WeakReference) CreateRouterWithCapturedTransport() { object transport = new object(); var weak = new WeakReference(transport); var router = NewRouter(sendTalk: _ => GC.KeepAlive(transport)); return (router, weak); } [MethodImpl(MethodImplOptions.NoInlining)] private static (LiveSessionCommandRouter, WeakReference, Action) CreateRouterWithDelayedConfirmation() { object transport = new object(); var weak = new WeakReference(transport); Action? callback = null; ClientCommandController.Bindings bindings = NewClientBindings() with { ShowConfirmation = (_, completion) => callback = completion, Suicide = () => GC.KeepAlive(transport), }; var router = NewRouter(clientBindings: bindings); router.Activate(); router.Publish(new ExecuteClientCommandCmd(ClientCommandId.Die, string.Empty)); return (router, weak, Assert.IsType>(callback)); } [MethodImpl(MethodImplOptions.NoInlining)] private static void ForceFullCollection() { GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); } private static ClientCommandController.Bindings NewClientBindings() => new( TeleportToLifestone: () => { }, TeleportToMarketplace: () => { }, TeleportToPkArena: () => { }, TeleportToPkLiteArena: () => { }, TeleportToHouse: () => { }, TeleportToMansion: () => { }, QueryAge: () => { }, QueryBirth: () => { }, ToggleFrameRate: () => { }, ToggleUiLock: () => { }, ShowSystemMessage: _ => { }, ShowWeenieError: _ => { }, PlayerPublicWeenieBitfield: () => null, ClientVersion: () => "test", CurrentPosition: () => null, LastOutsideCorpsePosition: () => null, ShowConfirmation: (_, _) => { }, Suicide: () => { }, ClearChat: _ => { }, SaveUi: _ => { }, LoadUi: _ => { }, SaveAutoUi: () => { }, LoadAutoUi: () => { }, IsAway: () => false, SetAway: _ => { }, SetAwayMessage: _ => { }, AcceptLootPermits: () => false, SetAcceptLootPermits: _ => { }, DisplayConsent: () => { }, ClearConsent: () => { }, RemoveConsent: _ => { }, SendEmote: _ => { }, Friends: new FriendsState(), AddFriend: _ => { }, RemoveFriend: _ => { }, ClearFriends: () => { }, RequestLegacyFriends: () => { }, Squelch: new SquelchState(), ModifyCharacterSquelch: (_, _, _, _) => { }, ModifyAccountSquelch: (_, _) => { }, ModifyGlobalSquelch: (_, _) => { }, LastTeller: () => null, ClearDesiredComponents: () => { }, HasOpenVendor: () => false, FillComponentBuyList: (_, _) => { }); }