907 lines
33 KiB
C#
907 lines
33 KiB
C#
using System.Net;
|
|
using AcDream.App.Net;
|
|
using AcDream.App.Rendering;
|
|
using AcDream.Core.Net;
|
|
using AcDream.Core.Net.Messages;
|
|
using AcDream.UI.Abstractions;
|
|
|
|
namespace AcDream.App.Tests.Net;
|
|
|
|
public sealed class LiveSessionControllerTests
|
|
{
|
|
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() { }
|
|
}
|
|
|
|
private sealed class TestCommandBus : ICommandBus
|
|
{
|
|
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,
|
|
commands,
|
|
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.Same(host.CommandBuses[0], controller.Commands);
|
|
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);
|
|
Assert.Equal(NullCommandBus.Instance, controller.Commands);
|
|
}
|
|
|
|
[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);
|
|
Assert.Same(NullCommandBus.Instance, controller.Commands);
|
|
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]]);
|
|
Assert.Same(NullCommandBus.Instance, controller.Commands);
|
|
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];
|
|
|
|
Assert.Throws<InvalidOperationException>(controller.Tick);
|
|
|
|
Assert.False(controller.IsInWorld);
|
|
Assert.Null(controller.CurrentSession);
|
|
Assert.Equal(1, operations.DisposeCounts[session]);
|
|
}
|
|
|
|
[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.Same(NullCommandBus.Instance, controller.Commands);
|
|
Assert.Throws<ObjectDisposedException>(() => controller.Start(LiveOptions(), host));
|
|
}
|
|
|
|
[Fact]
|
|
public void ReentrantShutdownRetainsControllerAndDependentsUntilDeferredDisposeCompletes()
|
|
{
|
|
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 dependentDisposed = false;
|
|
var shutdown = new ResourceShutdownTransaction(
|
|
new ResourceShutdownStage("session lifetime",
|
|
[
|
|
new("live session", () =>
|
|
{
|
|
controller.Dispose();
|
|
if (!controller.IsDisposalComplete)
|
|
{
|
|
throw new InvalidOperationException(
|
|
"live-session disposal is still deferred");
|
|
}
|
|
}),
|
|
]),
|
|
new ResourceShutdownStage("session dependents",
|
|
[
|
|
new("streamer", () => dependentDisposed = true),
|
|
]));
|
|
operations.OnTick = () =>
|
|
{
|
|
Assert.Throws<AggregateException>(shutdown.CompleteOrThrow);
|
|
Assert.Equal(0, shutdown.CurrentStage);
|
|
Assert.False(dependentDisposed);
|
|
};
|
|
|
|
controller.Tick();
|
|
|
|
Assert.True(controller.IsDisposalComplete);
|
|
Assert.False(dependentDisposed);
|
|
shutdown.CompleteOrThrow();
|
|
Assert.True(shutdown.IsComplete);
|
|
Assert.True(dependentDisposed);
|
|
}
|
|
|
|
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",
|
|
name => environment.GetValueOrDefault(name));
|
|
}
|
|
|
|
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);
|
|
}
|