acdream/tests/AcDream.Runtime.Tests/Session/LiveSessionControllerTests.cs
Erik 7593078774 refactor(runtime): move session lifetime and ordered transport
Move the canonical WorldSession generation, connect/enter/tick/stop transaction, inbound subscription owner, and retryable teardown acknowledgements into AcDream.Runtime. Keep App as a borrowing graphical host with a single inertable command projection and no mirrored session state.

Validated by 79 Runtime tests, 3,776 App tests with three existing skips, the Release solution build, and 8,428 complete Release tests with five existing skips.

Co-authored-by: Codex <codex@openai.com>
2026-07-25 19:39:24 +02:00

917 lines
33 KiB
C#

using System.Net;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Runtime;
using AcDream.Runtime.Session;
namespace AcDream.Runtime.Tests.Session;
public sealed class LiveSessionControllerTests
{
private sealed class TestTransport : IWorldSessionTransport
{
public void Send(ReadOnlySpan<byte> datagram) { }
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram) { }
public int Receive(
Span<byte> destination,
TimeSpan timeout,
out IPEndPoint? from)
{
from = null;
return -1;
}
public ValueTask<NetReceiveResult> ReceiveAsync(
Memory<byte> destination,
CancellationToken cancellationToken) =>
throw new OperationCanceledException(cancellationToken);
public void Dispose() { }
}
private sealed class TestCommandBus
{
public bool Active { get; set; }
public int PublishCount { get; private set; }
public void Publish<T>(T command) where T : notnull
{
if (Active)
PublishCount++;
}
}
private sealed class TestOperations(List<string> calls) : ILiveSessionOperations
{
public CharacterList.Parsed? Characters { get; set; } = AvailableCharacters();
public Action? OnResolve { get; set; }
public Action? OnCreate { get; set; }
public Action? OnConnect { get; set; }
public Action? OnCharacters { get; set; }
public Action? OnEnterWorld { get; set; }
public Action? OnTick { get; set; }
public Action? OnDispose { get; set; }
public bool ThrowOnConnect { get; set; }
public bool ThrowOnCreate { get; set; }
public bool ThrowOnCharacters { get; set; }
public bool ThrowOnEnterWorld { get; set; }
public bool ThrowOnTick { get; set; }
public bool FailDisposeOnce { get; set; }
public List<WorldSession> Sessions { get; } = [];
public Dictionary<WorldSession, int> DisposeCounts { get; } = [];
public int EnterWorldCount { get; private set; }
public int TickCount { get; private set; }
public IPEndPoint ResolveEndpoint(string host, int port)
{
calls.Add("resolve");
OnResolve?.Invoke();
return new IPEndPoint(IPAddress.Loopback, port);
}
public WorldSession CreateSession(IPEndPoint endpoint)
{
calls.Add("create");
OnCreate?.Invoke();
if (ThrowOnCreate)
throw new InvalidOperationException("create failure");
var session = new WorldSession(endpoint, new TestTransport());
Sessions.Add(session);
return session;
}
public void Connect(WorldSession session, string user, string password)
{
calls.Add("connect");
OnConnect?.Invoke();
if (ThrowOnConnect)
throw new InvalidOperationException("connect failure");
}
public CharacterList.Parsed? GetCharacters(WorldSession session)
{
OnCharacters?.Invoke();
if (ThrowOnCharacters)
throw new InvalidOperationException("characters failure");
return Characters;
}
public void EnterWorld(WorldSession session, int activeCharacterIndex)
{
calls.Add($"enter:{activeCharacterIndex}");
EnterWorldCount++;
OnEnterWorld?.Invoke();
if (ThrowOnEnterWorld)
throw new InvalidOperationException("enter failure");
}
public void Tick(WorldSession session)
{
calls.Add("tick");
TickCount++;
OnTick?.Invoke();
if (ThrowOnTick)
throw new InvalidOperationException("tick failure");
}
public void DisposeSession(WorldSession session)
{
calls.Add("dispose-session");
OnDispose?.Invoke();
if (FailDisposeOnce)
{
FailDisposeOnce = false;
throw new InvalidOperationException("dispose failure");
}
DisposeCounts[session] = DisposeCounts.GetValueOrDefault(session) + 1;
}
}
private sealed class TestHost(List<string> calls) : ILiveSessionLifecycleHost
{
public Action? OnBind { get; set; }
public Action? OnReset { get; set; }
public Action? OnConnecting { get; set; }
public Action? OnConnected { get; set; }
public Action? OnSelected { get; set; }
public Action? OnActivate { get; set; }
public Action? OnEntered { get; set; }
public Action? OnDetach { get; set; }
public bool FailResetOnce { get; set; }
public bool FailDetachOnce { get; set; }
public bool FailDeactivateOnce { get; set; }
public bool FailEventDetachOnce { get; set; }
public bool ThrowOnBind { get; set; }
public bool ThrowOnConnecting { get; set; }
public bool ThrowOnConnected { get; set; }
public bool ThrowOnSelected { get; set; }
public bool ThrowOnActivate { get; set; }
public bool ThrowOnEntered { get; set; }
public WorldSession? BindingSessionOverride { get; set; }
public int ResetCount { get; private set; }
public int DetachCount { get; private set; }
public int DeactivateCount { get; private set; }
public int EventDetachCount { get; private set; }
public int ActivateCount { get; private set; }
public List<TestCommandBus> CommandBuses { get; } = [];
public List<LiveSessionCharacterSelection> Selections { get; } = [];
public LiveSessionBinding BindSession(WorldSession session)
{
calls.Add("bind");
OnBind?.Invoke();
if (ThrowOnBind)
throw new InvalidOperationException("bind failure");
var commands = new TestCommandBus();
CommandBuses.Add(commands);
return new LiveSessionBinding(
BindingSessionOverride ?? session,
activateCommands: () =>
{
calls.Add("activate");
ActivateCount++;
commands.Active = true;
OnActivate?.Invoke();
if (ThrowOnActivate)
throw new InvalidOperationException("activate failure");
},
deactivateCommands: () =>
{
calls.Add("deactivate");
DeactivateCount++;
commands.Active = false;
if (FailDeactivateOnce)
{
FailDeactivateOnce = false;
throw new InvalidOperationException("deactivate failure");
}
},
detachEvents: () =>
{
calls.Add("detach-events");
EventDetachCount++;
if (FailEventDetachOnce)
{
FailEventDetachOnce = false;
throw new InvalidOperationException("event detach failure");
}
});
}
public void ResetSessionState()
{
calls.Add("reset");
ResetCount++;
OnReset?.Invoke();
if (FailResetOnce)
{
FailResetOnce = false;
throw new InvalidOperationException("reset failure");
}
}
public void ReportConnecting(string host, int port, string user)
{
calls.Add("report-connecting");
OnConnecting?.Invoke();
if (ThrowOnConnecting)
throw new InvalidOperationException("connecting failure");
}
public void ReportConnected()
{
calls.Add("report-connected");
OnConnected?.Invoke();
if (ThrowOnConnected)
throw new InvalidOperationException("connected failure");
}
public void ApplySelectedCharacter(LiveSessionCharacterSelection selection)
{
calls.Add("selected");
Selections.Add(selection);
OnSelected?.Invoke();
if (ThrowOnSelected)
throw new InvalidOperationException("selected failure");
}
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection)
{
calls.Add("entered");
OnEntered?.Invoke();
if (ThrowOnEntered)
throw new InvalidOperationException("entered failure");
}
public void DetachSession(WorldSession session)
{
calls.Add("detach-session");
DetachCount++;
OnDetach?.Invoke();
if (FailDetachOnce)
{
FailDetachOnce = false;
throw new InvalidOperationException("detach failure");
}
}
}
public enum ReentrantStopPoint
{
Reset,
Resolve,
Create,
Bind,
Connecting,
Connect,
Connected,
Selected,
EnterWorld,
Activate,
Entered,
}
[Fact]
public void Start_BindsBeforeConnectAndPublishesCanonicalSelectionAfterEnterWorld()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Connected, result.Status);
Assert.Equal(
[
"reset", "resolve", "create", "bind", "report-connecting",
"connect", "report-connected", "selected", "enter:1",
"activate", "entered",
],
calls);
Assert.Equal(
new LiveSessionCharacterSelection(1, 0x50000002u, "Ready", "Canonical"),
result.Selection);
Assert.True(controller.IsInWorld);
Assert.Same(operations.Sessions[0], controller.CurrentSession);
Assert.True(host.CommandBuses[0].Active);
}
[Fact]
public void Start_DisabledAndMissingCredentialsResetButNeverConstructSession()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
Assert.Equal(
LiveSessionStartStatus.Disabled,
controller.Start(LiveOptions(live: false), host).Status);
Assert.Equal(
LiveSessionStartStatus.MissingCredentials,
controller.Start(LiveOptions(user: null), host).Status);
Assert.Equal(2, host.ResetCount);
Assert.Empty(operations.Sessions);
}
[Fact]
public void Start_NoAvailableCharacterTearsDownExactScope()
{
var calls = new List<string>();
var operations = new TestOperations(calls)
{
Characters = new CharacterList.Parsed(
0u,
[new CharacterList.Character(0x50000001u, "Grey", 5u)],
[],
11,
"Canonical",
true,
true),
};
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.NoCharacters, result.Status);
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
Assert.Equal(1, host.DeactivateCount);
Assert.Equal(1, host.EventDetachCount);
Assert.Equal(1, host.DetachCount);
Assert.Equal(2, host.ResetCount);
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
}
[Fact]
public void Start_ConnectFailureConvergesOffline()
{
var calls = new List<string>();
var operations = new TestOperations(calls) { ThrowOnConnect = true };
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Failed, result.Status);
Assert.Contains("connect failure", result.Error!.ToString());
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
Assert.Equal(1, host.DetachCount);
}
[Theory]
[InlineData("create")]
[InlineData("bind")]
[InlineData("enter")]
public void Start_OtherConstructionAndEntryFailuresConvergeOffline(string phase)
{
var calls = new List<string>();
var operations = new TestOperations(calls)
{
ThrowOnCreate = phase == "create",
ThrowOnEnterWorld = phase == "enter",
};
var host = new TestHost(calls) { ThrowOnBind = phase == "bind" };
var controller = new LiveSessionController(operations);
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Failed, result.Status);
Assert.Contains($"{phase} failure", result.Error!.ToString());
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
if (phase == "create")
{
Assert.Empty(operations.Sessions);
Assert.Equal(1, host.ResetCount);
}
else
{
Assert.Single(operations.Sessions);
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
Assert.Equal(2, host.ResetCount);
}
Assert.Equal(phase == "enter" ? 1 : 0, host.DetachCount);
}
[Fact]
public void Start_HealthyDuplicateIsIdempotent()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
LiveSessionStartResult first = controller.Start(LiveOptions(), host);
LiveSessionStartResult duplicate = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Connected, duplicate.Status);
Assert.Equal(first.Selection, duplicate.Selection);
Assert.Single(operations.Sessions);
Assert.Equal(1, host.ResetCount);
Assert.Equal(1, host.ActivateCount);
}
[Fact]
public void Reconnect_QuiescesAThenDisposesDetachesResetsBeforeConstructingB()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
WorldSession sessionA = operations.Sessions[0];
TestCommandBus commandsA = host.CommandBuses[0];
calls.Clear();
LiveSessionStartResult result = controller.Reconnect(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Connected, result.Status);
Assert.Equal(
[
"deactivate", "detach-events", "dispose-session", "detach-session",
"reset", "resolve", "create", "bind", "report-connecting",
"connect", "report-connected", "selected", "enter:1",
"activate", "entered",
],
calls);
commandsA.Publish(new object());
Assert.Equal(0, commandsA.PublishCount);
Assert.Equal(1, operations.DisposeCounts[sessionA]);
Assert.Equal(2, operations.Sessions.Count);
Assert.Same(operations.Sessions[1], controller.CurrentSession);
}
[Theory]
[InlineData("detach", "stop")]
[InlineData("detach", "dispose")]
[InlineData("detach", "reconnect")]
[InlineData("reset", "stop")]
[InlineData("reset", "dispose")]
[InlineData("reset", "reconnect")]
public void Reconnect_ReentrantTeardownRequestNeverPublishesSupersededB(
string callbackPhase,
string request)
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
bool requested = false;
Action callback = () =>
{
if (requested)
return;
requested = true;
switch (request)
{
case "stop": controller.Stop(); break;
case "dispose": controller.Dispose(); break;
case "reconnect":
Assert.Equal(
LiveSessionStartStatus.Deferred,
controller.Reconnect(LiveOptions(), host).Status);
break;
}
};
if (callbackPhase == "detach")
host.OnDetach = callback;
else
host.OnReset = callback;
LiveSessionStartResult outer = controller.Reconnect(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Deferred, outer.Status);
Assert.Equal(request == "reconnect" ? 2 : 1, operations.Sessions.Count);
Assert.Equal(request == "reconnect", controller.IsInWorld);
if (request == "dispose")
Assert.Throws<ObjectDisposedException>(() => controller.Start(LiveOptions(), host));
controller.Dispose();
}
[Fact]
public void MismatchedBindingWithInterruptedDetachRetainsCleanupOwnershipForRetry()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
using var mismatchedSession = new WorldSession(
new IPEndPoint(IPAddress.Loopback, 9001),
new TestTransport());
var host = new TestHost(calls)
{
BindingSessionOverride = mismatchedSession,
FailEventDetachOnce = true,
};
var controller = new LiveSessionController(operations);
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Failed, result.Status);
Assert.Contains("different session", result.Error!.ToString());
Assert.Equal(0, operations.DisposeCounts.GetValueOrDefault(operations.Sessions[0]));
Assert.Equal(1, host.DeactivateCount);
Assert.Equal(1, host.EventDetachCount);
controller.Stop();
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
Assert.Equal(1, host.DeactivateCount);
Assert.Equal(2, host.EventDetachCount);
Assert.Equal(1, host.DetachCount);
controller.Dispose();
}
[Theory]
[InlineData(ReentrantStopPoint.Reset)]
[InlineData(ReentrantStopPoint.Resolve)]
[InlineData(ReentrantStopPoint.Create)]
[InlineData(ReentrantStopPoint.Bind)]
[InlineData(ReentrantStopPoint.Connecting)]
[InlineData(ReentrantStopPoint.Connect)]
[InlineData(ReentrantStopPoint.Connected)]
[InlineData(ReentrantStopPoint.Selected)]
[InlineData(ReentrantStopPoint.EnterWorld)]
[InlineData(ReentrantStopPoint.Activate)]
[InlineData(ReentrantStopPoint.Entered)]
public void ReentrantStop_InvalidatesOuterGenerationAndCannotResurrect(
ReentrantStopPoint point)
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
Action stop = controller.Stop;
switch (point)
{
case ReentrantStopPoint.Reset: host.OnReset = stop; break;
case ReentrantStopPoint.Resolve: operations.OnResolve = stop; break;
case ReentrantStopPoint.Create: operations.OnCreate = stop; break;
case ReentrantStopPoint.Bind: host.OnBind = stop; break;
case ReentrantStopPoint.Connecting: host.OnConnecting = stop; break;
case ReentrantStopPoint.Connect: operations.OnConnect = stop; break;
case ReentrantStopPoint.Connected: host.OnConnected = stop; break;
case ReentrantStopPoint.Selected: host.OnSelected = stop; break;
case ReentrantStopPoint.EnterWorld: operations.OnEnterWorld = stop; break;
case ReentrantStopPoint.Activate: host.OnActivate = stop; break;
case ReentrantStopPoint.Entered: host.OnEntered = stop; break;
}
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Deferred, result.Status);
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
if (operations.Sessions.Count == 0)
{
Assert.Contains(
point,
new[] { ReentrantStopPoint.Reset, ReentrantStopPoint.Resolve });
}
else
{
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
}
Assert.Equal(
point is ReentrantStopPoint.Reset
or ReentrantStopPoint.Resolve
or ReentrantStopPoint.Create
? 0
: 1,
host.DetachCount);
}
[Fact]
public void ReentrantDuplicateStartIsDeferredWithoutDisturbingOuterAttempt()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
LiveSessionStartResult? nested = null;
host.OnSelected = () => nested = controller.Start(LiveOptions(), host);
LiveSessionStartResult outer = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Deferred, nested!.Status);
Assert.Equal(LiveSessionStartStatus.Connected, outer.Status);
Assert.True(controller.IsInWorld);
Assert.Single(operations.Sessions);
}
[Fact]
public void ReentrantStartFromFailingEnteredCallbackCannotObserveUncommittedSession()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
LiveSessionStartResult? nested = null;
host.OnEntered = () =>
{
nested = controller.Start(LiveOptions(), host);
throw new InvalidOperationException("entered failure");
};
LiveSessionStartResult outer = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Deferred, nested!.Status);
Assert.Equal(LiveSessionStartStatus.Failed, outer.Status);
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
controller.Dispose();
}
[Theory]
[InlineData("reconnect")]
[InlineData("dispose")]
public void ReentrantLifecycleRequestDuringActivationDoesNotLeakActiveCommands(
string request)
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
bool requested = false;
host.OnActivate = () =>
{
if (requested)
return;
requested = true;
if (request == "reconnect")
controller.Reconnect(LiveOptions(), host);
else
controller.Dispose();
};
LiveSessionStartResult outer = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Deferred, outer.Status);
Assert.False(host.CommandBuses[0].Active);
Assert.Equal(request == "reconnect" ? 2 : 1, operations.Sessions.Count);
Assert.Equal(request == "reconnect", controller.IsInWorld);
controller.Dispose();
}
[Theory]
[InlineData("connecting")]
[InlineData("connected")]
[InlineData("characters")]
[InlineData("selected")]
[InlineData("activate")]
[InlineData("entered")]
public void Start_CallbackFailureConvergesOffline(string phase)
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
switch (phase)
{
case "connecting": host.ThrowOnConnecting = true; break;
case "connected": host.ThrowOnConnected = true; break;
case "characters": operations.ThrowOnCharacters = true; break;
case "selected": host.ThrowOnSelected = true; break;
case "activate": host.ThrowOnActivate = true; break;
case "entered": host.ThrowOnEntered = true; break;
}
var controller = new LiveSessionController(operations);
LiveSessionStartResult result = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Failed, result.Status);
Assert.Contains($"{phase} failure", result.Error!.ToString());
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
Assert.Equal(1, operations.DisposeCounts[operations.Sessions[0]]);
controller.Dispose();
}
[Theory]
[InlineData("deactivate")]
[InlineData("events")]
[InlineData("session")]
[InlineData("detach")]
[InlineData("reset")]
public void Stop_RetriesExactFailedTeardownStageWithoutRepeatingCompletedWork(
string phase)
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
WorldSession session = operations.Sessions[0];
switch (phase)
{
case "deactivate": host.FailDeactivateOnce = true; break;
case "events": host.FailEventDetachOnce = true; break;
case "session": operations.FailDisposeOnce = true; break;
case "detach": host.FailDetachOnce = true; break;
case "reset": host.FailResetOnce = true; break;
}
Assert.Throws<InvalidOperationException>(controller.Stop);
controller.Stop();
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
Assert.Equal(1, operations.DisposeCounts[session]);
Assert.Equal(phase == "detach" ? 2 : 1, host.DetachCount);
Assert.Equal(phase == "reset" ? 3 : 2, host.ResetCount);
controller.Dispose();
}
[Fact]
public void ReconnectRequestedDuringTickRunsAfterTickAndReplacesExactGeneration()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
WorldSession sessionA = operations.Sessions[0];
bool requested = false;
operations.OnTick = () =>
{
if (requested)
return;
requested = true;
LiveSessionStartResult nested = controller.Reconnect(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Deferred, nested.Status);
};
controller.Tick();
Assert.True(controller.IsInWorld);
Assert.Equal(2, operations.Sessions.Count);
Assert.Equal(1, operations.DisposeCounts[sessionA]);
Assert.Same(operations.Sessions[1], controller.CurrentSession);
}
[Fact]
public void ResetFailureBlocksConstructionUntilRetryConverges()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls) { FailResetOnce = true };
var controller = new LiveSessionController(operations);
LiveSessionStartResult first = controller.Start(LiveOptions(), host);
LiveSessionStartResult second = controller.Start(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Failed, first.Status);
Assert.Equal(LiveSessionStartStatus.Connected, second.Status);
Assert.Equal(2, host.ResetCount);
Assert.Single(operations.Sessions);
}
[Fact]
public void FailedDetachRetainsRetiredScopeAndBlocksBFactoryUntilRetry()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls) { FailDetachOnce = true };
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
WorldSession sessionA = operations.Sessions[0];
LiveSessionStartResult first = controller.Reconnect(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Failed, first.Status);
Assert.Single(operations.Sessions);
Assert.Equal(1, operations.DisposeCounts[sessionA]);
Assert.False(controller.IsInWorld);
LiveSessionStartResult retry = controller.Reconnect(LiveOptions(), host);
Assert.Equal(LiveSessionStartStatus.Connected, retry.Status);
Assert.Equal(2, operations.Sessions.Count);
Assert.Equal(1, operations.DisposeCounts[sessionA]);
Assert.Equal(2, host.DetachCount);
}
[Fact]
public void TickFailureCleansScopeBeforeRethrowing()
{
var calls = new List<string>();
var operations = new TestOperations(calls) { ThrowOnTick = true };
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
WorldSession session = operations.Sessions[0];
InvalidOperationException error =
Assert.Throws<InvalidOperationException>(controller.Tick);
Assert.False(controller.IsInWorld);
Assert.Null(controller.CurrentSession);
Assert.Equal(1, operations.DisposeCounts[session]);
Assert.Contains(
$"{nameof(TestOperations)}.{nameof(TestOperations.Tick)}",
error.StackTrace,
StringComparison.Ordinal);
}
[Fact]
public void DisposeIsIdempotentAndMakesOldCommandsInert()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
WorldSession session = operations.Sessions[0];
TestCommandBus commands = host.CommandBuses[0];
controller.Dispose();
controller.Dispose();
commands.Publish(new object());
Assert.Equal(1, operations.DisposeCounts[session]);
Assert.Equal(0, commands.PublishCount);
Assert.False(controller.IsInWorld);
Assert.Throws<ObjectDisposedException>(() => controller.Start(LiveOptions(), host));
}
[Fact]
public void GenerationScopedStopAcknowledgesTheCompleteTeardownTransaction()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls);
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
RuntimeGenerationToken retired = controller.Generation;
RuntimeTeardownAcknowledgement acknowledgement =
controller.Stop(retired);
Assert.True(acknowledgement.IsComplete);
Assert.Equal(retired, acknowledgement.RetiredGeneration);
Assert.Equal(controller.Generation, acknowledgement.CurrentGeneration);
Assert.Equal(
RuntimeTeardownStage.Complete,
acknowledgement.CompletedStages);
}
[Fact]
public void FailedStopAcknowledgesOnlyCompletedPrefixAndRetryDrainsSuffix()
{
var calls = new List<string>();
var operations = new TestOperations(calls);
var host = new TestHost(calls)
{
FailEventDetachOnce = true,
};
var controller = new LiveSessionController(operations);
controller.Start(LiveOptions(), host);
RuntimeTeardownAcknowledgement failed =
controller.Stop(controller.Generation);
Assert.Equal(RuntimeCommandStatus.Rejected, failed.Status);
Assert.True(
(failed.CompletedStages & RuntimeTeardownStage.CommandsInert) != 0);
Assert.True(
(failed.CompletedStages & RuntimeTeardownStage.InboundDetached) == 0);
Assert.True(
(failed.CompletedStages & RuntimeTeardownStage.TransportDisposed) == 0);
RuntimeTeardownAcknowledgement retry =
controller.Stop(failed.CurrentGeneration);
Assert.True(retry.IsComplete);
Assert.Equal(1, host.DeactivateCount);
Assert.Equal(2, host.EventDetachCount);
Assert.Single(operations.DisposeCounts);
}
private static LiveSessionConnectOptions LiveOptions(
bool live = true,
string? user = "user") =>
new(
live,
"127.0.0.1",
9000,
user ?? string.Empty,
"password");
private static CharacterList.Parsed AvailableCharacters() => new(
0u,
[
new CharacterList.Character(0x50000001u, "Grey", 10u),
new CharacterList.Character(0x50000002u, "Ready", 0u),
],
[new CharacterList.Character(0x50000003u, "Deleted", 0u)],
11,
"Canonical",
true,
true);
}