Applies the seven NITs from the CH2 re-review (verdict APPROVE-WITH-FIXES, following the REJECT->rework ate0e78883): 1. SpewBoxController's centered Left was captured once via AnchorEdges.Top and replayed forever on resize (UiElement.ApplyAnchor's Left/Right-both-false branch pins a fixed margin). Anchors is now AnchorEdges.None and Tick recomputes Left every frame against the current root width. 2. OneLine=false was defaulting to UiText's bottom-pinned transcript flow (VerticalJustify honored only via ConfigureDatState, which this synthesized element never calls). Added UiText.HonorVerticalJustification so a non-DAT controller can opt the scrollable path into VerticalJustify without a full LayoutDesc binding; SpewBoxController sets VerticalJustify=Top so lines flow from the top of the 450x72 box, matching newest-at-top insert semantics. Noted as invented-pending- measurement in AP-178's row (no new row). 3. Documented the deliberate inversion of UiText.LinesProvider's oldest-first contract in SpewBoxController.Tick (SpewBoxVM.Lines feeds newest-first, which is correct specifically because the box is now top-aligned) and added a test pinning the rendered order (newer message is the topmost line), driving root.Tick. 4. Fixed the stale "retail's code default, 1" comment in SpewBoxControllerTests — MaxConcurrentItems is the shipped LayoutDesc's AUTHORED value, 4. 5. Added the matching unmapped-id diagnostics line to LiveSessionRuntimeFactory's ShowWeenieError sink, matching the pattern GameEventWiring's WeenieError/WeenieErrorWithString handlers already use. 6. Corrected the "EXHAUSTIVE Portal sweep found ZERO" overclaim in SpewBoxLayoutDumpDiagnostic: the loop's id source was DatCollection's top-level aggregate GetAllIdsOfType<LayoutDesc>(), not dats.Portal's own (which reports a count of ZERO for this type), so querying those ids against dats.Portal.TryGet established nothing about Portal either way. Corrected the same overclaim echoed in SpewBoxState's MaxConcurrentItems doc comment and in AP-178's register text (both the table row and the section-header history line). What's actually established: dats.Local hosts the SpewBox layout at 0x21000011; whether Portal also carries a copy remains unestablished. 7. Added a test exercising the full ShowWeenieError -> AddText -> SpewBox path for id 0x0561 (the 50-friends-cap refusal) in LiveSessionCommandRouterTests, mirroring LiveSessionRuntimeFactory's ShowWeenieError closure exactly since every other LiveSessionRuntimeFactory test in this tree is a source-text conformance grep, not an instantiation. Ledger: CH2 ledger row's review column now reads REJECT -> reworkede0e78883-> re-review APPROVE-WITH-FIXES -> nits (this commit); Status header flips CH2 to code-complete/closed pending the user gate, CH3 next. Build green; touched-project tests green (19/19 new/changed, 4351/3354 App.Tests unaffected pass); full Release suite 11,916 passed / 4 skipped / 0 failed (baseline 11,914/4/0 plus the two new tests this commit adds). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
428 lines
16 KiB
C#
428 lines
16 KiB
C#
using System.Runtime.CompilerServices;
|
|
using AcDream.App.Net;
|
|
using AcDream.App.UI;
|
|
using AcDream.Core.Chat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Social;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.UI.Abstractions;
|
|
|
|
namespace AcDream.App.Tests.Net;
|
|
|
|
public sealed class LiveSessionCommandRouterTests
|
|
{
|
|
[Fact]
|
|
public void InactiveAndDisposedRouter_CannotReachTransport()
|
|
{
|
|
var sent = new List<string>();
|
|
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 CommandSurfacePublishesToOneBorrowedRouteAndRetiresStaleRoute()
|
|
{
|
|
var sent = new List<string>();
|
|
var surface = new LiveSessionCommandSurface();
|
|
var first = NewRouter(sendTalk: text => sent.Add($"first:{text}"));
|
|
var firstLease = surface.Attach(first);
|
|
firstLease.Activate();
|
|
|
|
surface.Publish(new SendServerCommandCmd("@one"));
|
|
var second = NewRouter(sendTalk: text => sent.Add($"second:{text}"));
|
|
Assert.Throws<InvalidOperationException>(() => surface.Attach(second));
|
|
|
|
firstLease.Dispose();
|
|
first.Publish(new SendServerCommandCmd("@stale"));
|
|
var secondLease = surface.Attach(second);
|
|
secondLease.Activate();
|
|
surface.Publish(new SendServerCommandCmd("@two"));
|
|
secondLease.Dispose();
|
|
|
|
Assert.Equal(["first:@one", "second:@two"], sent);
|
|
Assert.False(first.IsActive);
|
|
Assert.False(second.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<ObjectDisposedException>(router.Activate);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task ConcurrentDispose_WaitsForInFlightTransportThenMakesRouterInert()
|
|
{
|
|
using var sendEntered = new ManualResetEventSlim();
|
|
using var releaseSend = new ManualResetEventSlim();
|
|
var sent = new List<string>();
|
|
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<string>();
|
|
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<object> transport) =
|
|
CreateRouterWithCapturedTransport();
|
|
|
|
router.Dispose();
|
|
ForceFullCollection();
|
|
|
|
Assert.False(transport.TryGetTarget(out _));
|
|
GC.KeepAlive(router);
|
|
}
|
|
|
|
[Fact]
|
|
public void DelayedConfirmationCallback_ReleasesTransportAndBecomesInert()
|
|
{
|
|
(LiveSessionCommandRouter router, WeakReference<object> transport, Action<bool> callback) =
|
|
CreateRouterWithDelayedConfirmation();
|
|
|
|
router.Dispose();
|
|
ForceFullCollection();
|
|
|
|
Assert.False(transport.TryGetTarget(out _));
|
|
callback(true);
|
|
Assert.False(router.IsActive);
|
|
GC.KeepAlive(callback);
|
|
GC.KeepAlive(router);
|
|
}
|
|
|
|
[Fact]
|
|
public void RuntimeStateCommandsUseActiveGenerationRouteAndBecomeInert()
|
|
{
|
|
var shortcuts = new List<ShortcutEntry>();
|
|
var options = new List<uint>();
|
|
LiveSessionCommandRouter router = NewRouter(
|
|
addShortcut: shortcuts.Add,
|
|
setCharacterOptions: options.Add);
|
|
|
|
router.Publish(new AddShortcutRuntimeCmd(
|
|
new ShortcutEntry(1, 0x80000001u, 0u)));
|
|
router.Activate();
|
|
router.Publish(new AddShortcutRuntimeCmd(
|
|
new ShortcutEntry(2, 0x80000002u, 0u)));
|
|
router.Publish(new SetCharacterOptionsRuntimeCmd(0x50C4A54Au));
|
|
router.Dispose();
|
|
router.Publish(new AddShortcutRuntimeCmd(
|
|
new ShortcutEntry(3, 0x80000003u, 0u)));
|
|
|
|
Assert.Collection(
|
|
shortcuts,
|
|
entry =>
|
|
{
|
|
Assert.Equal(2, entry.Index);
|
|
Assert.Equal(0x80000002u, entry.ObjectId);
|
|
});
|
|
Assert.Equal([0x50C4A54Au], options);
|
|
}
|
|
|
|
[Fact]
|
|
public void ShowWeenieErrorFriendsFull_ResolvesThroughAddText_AndLandsInSpewBoxNotChat()
|
|
{
|
|
// CH2 re-review nit 7 (docs/plans/2026-08-09-chat-parity-campaign.md):
|
|
// exercises the FULL retail path for id 0x0561 (the 50-friends-cap
|
|
// refusal): ClientCommandController.AddFriend's cap guard calls
|
|
// ShowWeenieError(0x0561u); the binding below mirrors
|
|
// LiveSessionRuntimeFactory.CreateCommandBindings' ShowWeenieError
|
|
// closure exactly (WeenieErrorMessages.Resolve then
|
|
// RuntimeCommunicationState.AddText) — the same chokepoint that
|
|
// fans a ClientLocal line out to SpewBox instead of chat
|
|
// (RuntimeCommunicationStateTests pins that half in isolation
|
|
// already). This is "the closest seam" per the review: every other
|
|
// LiveSessionRuntimeFactory test in this tree is a source-text
|
|
// conformance grep (ReadSource), not an instantiation — the real
|
|
// factory needs a live WorldSession/domain graph no test builds.
|
|
var communication = new RuntimeCommunicationState();
|
|
ClientCommandController.Bindings bindings = NewClientBindings() with
|
|
{
|
|
Friends = communication.Friends,
|
|
ShowWeenieError = code =>
|
|
{
|
|
(string? text, RetailLogTextType type) = WeenieErrorMessages.Resolve(code, null);
|
|
if (text is not null)
|
|
communication.AddText(text, type);
|
|
},
|
|
};
|
|
LiveSessionCommandRouter router = NewRouter(clientBindings: bindings);
|
|
router.Activate();
|
|
|
|
// Seed 50 friends so AddFriend's cap guard fires
|
|
// (ClientCommandController.cs's AddFriend).
|
|
var seeded = new List<FriendEntry>();
|
|
for (uint i = 0; i < 50; i++)
|
|
seeded.Add(new FriendEntry(i + 1, $"Friend{i}", true, false, [], []));
|
|
communication.Friends.Apply(new FriendsUpdate(FriendsUpdateType.Full, seeded));
|
|
|
|
router.Publish(new ExecuteClientCommandCmd(ClientCommandId.FriendsAdd, "OneTooMany"));
|
|
|
|
communication.SpewBox.Tick(0d);
|
|
Assert.Equal(1, communication.SpewBox.Count);
|
|
Assert.Equal(
|
|
"You may only have a maximum of 50 friends at once. If you wish to add more friends, you must first remove some.",
|
|
communication.SpewBox.Snapshot()[0].Text);
|
|
Assert.Equal(0, communication.Chat.Count);
|
|
Assert.Equal(50, communication.Friends.Count);
|
|
}
|
|
|
|
private static LiveSessionCommandRouter NewRouter(
|
|
ChatLog? chat = null,
|
|
TurbineChatState? turbine = null,
|
|
Func<uint>? playerGuid = null,
|
|
Action<string>? sendTalk = null,
|
|
Action<string, string>? sendTell = null,
|
|
Action<uint, string>? sendChannel = null,
|
|
Action<uint, uint, uint, uint, string, uint>? sendTurbine = null,
|
|
Action<ShortcutEntry>? addShortcut = null,
|
|
Action<uint>? setCharacterOptions = null,
|
|
Action<string>? log = null,
|
|
ClientCommandController.Bindings? clientBindings = null) => new(
|
|
new LiveSessionCommandBindings(
|
|
clientBindings ?? NewClientBindings(),
|
|
chat ?? new ChatLog(),
|
|
turbine ?? new TurbineChatState(),
|
|
playerGuid ?? (() => 0u),
|
|
sendTalk ?? (_ => { }),
|
|
sendTell ?? ((_, _) => { }),
|
|
sendChannel ?? ((_, _) => { }),
|
|
sendTurbine ?? ((_, _, _, _, _, _) => { }),
|
|
AddShortcut: addShortcut ?? (_ => { }),
|
|
RemoveShortcut: _ => { },
|
|
AddFavorite: (_, _, _) => { },
|
|
RemoveFavorite: (_, _) => { },
|
|
SetSpellbookFilter: _ => { },
|
|
ForgetSpell: _ => { },
|
|
SetDesiredComponent: (_, _) => { },
|
|
ClearDesiredComponents: () => { },
|
|
RaiseAttribute: (_, _) => { },
|
|
RaiseVital: (_, _) => { },
|
|
RaiseSkill: (_, _) => { },
|
|
TrainSkill: (_, _) => { },
|
|
SetCharacterOptions: setCharacterOptions ?? (_ => { }),
|
|
AddFriend: _ => { },
|
|
RemoveFriend: _ => { },
|
|
ClearFriends: () => { },
|
|
RequestLegacyFriends: () => { },
|
|
ModifyCharacterSquelch: (_, _, _, _) => { },
|
|
ModifyAccountSquelch: (_, _) => { },
|
|
ModifyGlobalSquelch: (_, _) => { },
|
|
Log: log));
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private static (LiveSessionCommandRouter, WeakReference<object>)
|
|
CreateRouterWithCapturedTransport()
|
|
{
|
|
object transport = new object();
|
|
var weak = new WeakReference<object>(transport);
|
|
var router = NewRouter(sendTalk: _ => GC.KeepAlive(transport));
|
|
return (router, weak);
|
|
}
|
|
|
|
[MethodImpl(MethodImplOptions.NoInlining)]
|
|
private static (LiveSessionCommandRouter, WeakReference<object>, Action<bool>)
|
|
CreateRouterWithDelayedConfirmation()
|
|
{
|
|
object transport = new object();
|
|
var weak = new WeakReference<object>(transport);
|
|
Action<bool>? 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<Action<bool>>(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: (_, _) => { },
|
|
EnterPkLite: () => { });
|
|
}
|