acdream/tests/AcDream.App.Tests/Net/LiveSessionHostTests.cs
Erik 557eb7ef6b refactor(net): own live session composition
Extract reset, selection, entered-world, and route construction behind LiveSessionHost while preserving the sole LiveSessionController authority. Retain partial route and subscription cleanup for retry, and replace the embedded ACE-only shortcut with the exact named-retail unsigned skill formula.

Co-authored-by: Codex <codex@openai.com>
2026-07-22 10:36:06 +02:00

391 lines
13 KiB
C#

using System.Net;
using AcDream.App.Net;
using AcDream.App.Rendering;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
namespace AcDream.App.Tests.Net;
public sealed class LiveSessionHostTests
{
[Fact]
public void HostOwnsRouteSelectionAndEntryOrderingWithoutMirroringSession()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var controller = new LiveSessionController(operations);
var events = new TestEventRouting(calls);
var commands = new TestCommandRouting(calls);
LiveSessionHost host = CreateHost(
controller,
calls,
_ =>
{
calls.Add("events");
return events;
},
_ =>
{
calls.Add("commands");
return commands;
});
LiveSessionStartResult result = host.Start(LiveOptions());
Assert.Equal(LiveSessionStartStatus.Connected, result.Status);
Assert.Equal(
[
"reset", "resolve", "create", "events", "attach-events", "commands",
"connecting", "connect", "connected",
"player:1342177282", "vitals:1342177282",
"chat:1342177282", "persistent:1342177282",
"vanish:1342177282", "clear-combat", "enter:1",
"activate", "active:Ready", "restore-layout",
"sync-toolbar", "load-settings:Ready", "arm-auto-entry",
],
calls);
Assert.Same(controller.CurrentSession, host.CurrentSession);
Assert.Same(commands, host.Commands);
Assert.True(host.IsInWorld);
controller.Dispose();
}
[Fact]
public void DisabledAndMissingCredentialStartsExecuteTheCanonicalResetPlan()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var controller = new LiveSessionController(operations);
LiveSessionHost host = CreateHost(
controller,
calls,
_ => throw new InvalidOperationException("must not bind"),
_ => throw new InvalidOperationException("must not bind"));
Assert.Equal(
LiveSessionStartStatus.Disabled,
host.Start(LiveOptions(live: false)).Status);
Assert.Equal(
LiveSessionStartStatus.MissingCredentials,
host.Start(LiveOptions(user: null)).Status);
Assert.Equal(["reset", "reset"], calls);
Assert.Null(host.CurrentSession);
Assert.False(host.IsInWorld);
}
[Fact]
public void CommandFactoryFailureRollsBackTheAlreadyAttachedEventRoute()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var controller = new LiveSessionController(operations);
var events = new TestEventRouting(calls);
LiveSessionHost host = CreateHost(
controller,
calls,
_ => events,
_ => throw new InvalidOperationException("command failure"));
LiveSessionStartResult result = host.Start(LiveOptions());
Assert.Equal(LiveSessionStartStatus.Failed, result.Status);
Assert.IsType<InvalidOperationException>(result.Error);
Assert.Equal(1, events.DisposeCount);
Assert.Contains("dispose-events", calls);
Assert.Null(host.CurrentSession);
}
[Fact]
public void AttachFailureRetainsThePublishedRouteUntilRollbackConverges()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var controller = new LiveSessionController(operations);
var firstEvents = new TestEventRouting(calls)
{
AttachFailuresRemaining = 1,
FailuresRemaining = 1,
};
int eventFactoryCount = 0;
int commandFactoryCount = 0;
LiveSessionHost host = CreateHost(
controller,
calls,
_ => eventFactoryCount++ == 0
? firstEvents
: new TestEventRouting(calls),
_ =>
{
commandFactoryCount++;
return new TestCommandRouting(calls);
});
LiveSessionStartResult failed = host.Start(LiveOptions());
Assert.Equal(LiveSessionStartStatus.Failed, failed.Status);
Assert.Equal(1, firstEvents.AttachCount);
Assert.Equal(2, firstEvents.DisposeCount);
Assert.Equal(0, commandFactoryCount);
LiveSessionStartResult retry = host.Start(LiveOptions());
Assert.Equal(LiveSessionStartStatus.Connected, retry.Status);
Assert.Equal(2, eventFactoryCount);
Assert.Equal(1, commandFactoryCount);
controller.Dispose();
}
[Fact]
public void TransientRollbackFailureIsReportedRetriedAndConvergesBeforeNextStart()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var controller = new LiveSessionController(operations);
var firstEvents = new TestEventRouting(calls) { FailuresRemaining = 1 };
int eventFactoryCount = 0;
bool failCommands = true;
LiveSessionHost host = CreateHost(
controller,
calls,
_ => eventFactoryCount++ == 0
? firstEvents
: new TestEventRouting(calls),
_ =>
{
if (failCommands)
{
failCommands = false;
throw new InvalidOperationException("command failure");
}
return new TestCommandRouting(calls);
});
LiveSessionStartResult failed = host.Start(LiveOptions());
AggregateException error = Assert.IsType<AggregateException>(failed.Error);
Assert.Equal(2, error.InnerExceptions.Count);
Assert.Contains(error.InnerExceptions, e => e.Message == "command failure");
Assert.Contains(error.InnerExceptions, e => e.Message == "event cleanup failure");
Assert.Equal(2, firstEvents.DisposeCount);
LiveSessionStartResult retry = host.Start(LiveOptions());
Assert.Equal(LiveSessionStartStatus.Connected, retry.Status);
Assert.Equal(2, eventFactoryCount);
Assert.Equal(2, operations.Sessions.Count);
controller.Dispose();
}
[Fact]
public void PersistentRollbackFailureBlocksEveryLaterGeneration()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var controller = new LiveSessionController(operations);
var events = new TestEventRouting(calls)
{
FailuresRemaining = int.MaxValue,
};
int eventFactoryCount = 0;
int commandFactoryCount = 0;
LiveSessionHost host = CreateHost(
controller,
calls,
_ =>
{
eventFactoryCount++;
return events;
},
_ =>
{
commandFactoryCount++;
throw new InvalidOperationException("command failure");
});
LiveSessionStartResult first = host.Start(LiveOptions());
LiveSessionStartResult second = host.Start(LiveOptions());
Assert.Equal(LiveSessionStartStatus.Failed, first.Status);
Assert.Equal(LiveSessionStartStatus.Failed, second.Status);
Assert.Equal(3, events.DisposeCount);
Assert.Equal(1, eventFactoryCount);
Assert.Equal(1, commandFactoryCount);
Assert.Single(operations.Sessions);
Assert.Null(host.CurrentSession);
}
private static LiveSessionHost CreateHost(
LiveSessionController controller,
List<string> calls,
Func<WorldSession, ILiveSessionEventRouting> createEvents,
Func<WorldSession, ILiveSessionCommandRouting> createCommands) =>
new(controller, new LiveSessionHostBindings(
Routing: new(createEvents, createCommands),
Reset: ResetBindings(() => calls.Add("reset")),
Selection: new(
id => calls.Add($"player:{id}"),
id => calls.Add($"vitals:{id}"),
id => calls.Add($"chat:{id}"),
id => calls.Add($"persistent:{id}"),
id => calls.Add($"vanish:{id}"),
() => calls.Add("clear-combat")),
EnteredWorld: new(
name => calls.Add($"active:{name}"),
() => calls.Add("restore-layout"),
() => calls.Add("sync-toolbar"),
name => calls.Add($"load-settings:{name}"),
() => calls.Add("arm-auto-entry")),
Connecting: (_, _, _) => calls.Add("connecting"),
Connected: () => calls.Add("connected")));
private static LiveSessionResetBindings ResetBindings(Action reset)
{
Action noop = static () => { };
return new LiveSessionResetBindings
{
MouseCapture = reset,
PlayerPresentation = noop,
TeleportTransit = noop,
SessionDialogs = noop,
ChatCommandTargets = noop,
SettingsCharacterContext = noop,
EquippedChildren = noop,
ExternalContainer = noop,
InteractionAndSelection = noop,
SelectionPresentation = noop,
ObjectTable = noop,
Spellbook = noop,
MagicRuntime = noop,
CombatAttack = noop,
CombatState = noop,
ItemMana = noop,
LocalPlayer = noop,
Friends = noop,
Squelch = noop,
TurbineChat = noop,
ParticleVisibility = noop,
InboundEventFifo = noop,
LiveLiveness = noop,
LiveRuntime = noop,
SessionIdentity = noop,
RemoteTeleport = noop,
NetworkEffects = noop,
AnimationHookFrames = noop,
LivePresentation = noop,
RemoteMovementDiagnostics = noop,
};
}
private static RuntimeOptions LiveOptions(bool live = true, string? user = "user")
{
var environment = new Dictionary<string, string?>
{
["ACDREAM_LIVE"] = live ? "1" : "0",
["ACDREAM_TEST_HOST"] = "127.0.0.1",
["ACDREAM_TEST_PORT"] = "9000",
["ACDREAM_TEST_USER"] = user,
["ACDREAM_TEST_PASS"] = "password",
};
return RuntimeOptions.Parse("dat", environment.GetValueOrDefault);
}
private sealed class TestEventRouting(List<string> calls)
: ILiveSessionEventRouting
{
public int FailuresRemaining { get; set; }
public int AttachFailuresRemaining { get; set; }
public int AttachCount { get; private set; }
public int DisposeCount { get; private set; }
public void Attach()
{
AttachCount++;
calls.Add("attach-events");
if (AttachFailuresRemaining > 0)
{
AttachFailuresRemaining--;
throw new InvalidOperationException("event attach failure");
}
}
public void Dispose()
{
DisposeCount++;
calls.Add("dispose-events");
if (FailuresRemaining > 0)
{
FailuresRemaining--;
throw new InvalidOperationException("event cleanup failure");
}
}
}
private sealed class TestCommandRouting(List<string> calls)
: ILiveSessionCommandRouting
{
public void Activate() => calls.Add("activate");
public void Publish<T>(T command) where T : notnull { }
public void Dispose() => calls.Add("dispose-commands");
}
private sealed class TestOperations(List<string> calls) : ILiveSessionOperations
{
public List<WorldSession> Sessions { get; } = [];
public IPEndPoint ResolveEndpoint(string host, int port)
{
calls.Add("resolve");
return new IPEndPoint(IPAddress.Loopback, port);
}
public WorldSession CreateSession(IPEndPoint endpoint)
{
calls.Add("create");
var session = new WorldSession(endpoint, new TestTransport());
Sessions.Add(session);
return session;
}
public void Connect(WorldSession session, string user, string password) =>
calls.Add("connect");
public CharacterList.Parsed? GetCharacters(WorldSession session) => new(
0u,
[
new CharacterList.Character(0x50000001u, "Grey", 10u),
new CharacterList.Character(0x50000002u, "Ready", 0u),
],
[],
11,
"Canonical",
true,
true);
public void EnterWorld(WorldSession session, int activeCharacterIndex) =>
calls.Add($"enter:{activeCharacterIndex}");
public void Tick(WorldSession session) { }
public void DisposeSession(WorldSession session)
{
calls.Add("dispose-session");
session.Dispose();
}
}
private sealed class TestTransport : IWorldSessionTransport
{
public void Send(ReadOnlySpan<byte> datagram) { }
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
public byte[]? Receive(TimeSpan timeout, out IPEndPoint? from)
{
from = null;
return null;
}
public void Dispose() { }
}
}