diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md
index 2a0030bf6..af782d718 100644
--- a/RELEASE-NOTES.md
+++ b/RELEASE-NOTES.md
@@ -130,6 +130,7 @@ END TEMPLATE-->
* Add CC ND licences to the RGA validator.
* Add entity spawn prediction and entity deletion prediction. This is currently limited as you are unable to predict interactions with these entities. These are done via the new methods prefixed with "Predicted". You can also manually flag an entity as a predicted spawn with the `FlagPredicted` method which will clean it up when prediction is reset.
+* Added `PoolManager` & `TestPair` classes to `Robust.UnitTesting`. These classes make it easier to create & use pooled server/client instance pairs in integration tests.
### Bugfixes
diff --git a/Robust.UnitTesting/IIntegrationInstance.cs b/Robust.UnitTesting/IIntegrationInstance.cs
new file mode 100644
index 000000000..f7c00c1f5
--- /dev/null
+++ b/Robust.UnitTesting/IIntegrationInstance.cs
@@ -0,0 +1,118 @@
+using System;
+using System.Diagnostics.Contracts;
+using System.Threading;
+using System.Threading.Tasks;
+using Robust.Shared.Configuration;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Log;
+using Robust.Shared.Map;
+using Robust.Shared.Network;
+using Robust.Shared.Player;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Timing;
+
+namespace Robust.UnitTesting;
+
+public interface IIntegrationInstance : IDisposable
+{
+ ///
+ /// Whether the instance is still alive.
+ /// "Alive" indicates that it is able to receive and process commands.
+ ///
+ ///
+ /// Thrown if you did not ensure that the instance is idle via first.
+ ///
+ bool IsAlive { get; }
+
+ Exception? UnhandledException { get; }
+
+ EntityManager EntMan { get; }
+ IPrototypeManager ProtoMan { get; }
+ IConfigurationManager CfgMan { get; }
+ ISharedPlayerManager PlayerMan { get; }
+ INetManager NetMan { get; }
+ IMapManager MapMan { get; }
+ IGameTiming Timing { get; }
+ ISawmill Log { get; }
+
+ ///
+ /// Resolve a dependency inside the instance.
+ ///
+ ///
+ /// Thrown if you did not ensure that the instance is idle via first.
+ ///
+ [Pure] T Resolve();
+
+ [Pure] T System() where T : IEntitySystem;
+
+ TransformComponent Transform(EntityUid uid);
+ MetaDataComponent MetaData(EntityUid uid);
+ MetaDataComponent MetaData(NetEntity uid);
+ TransformComponent Transform(NetEntity uid);
+
+ Task ExecuteCommand(string cmd);
+
+ ///
+ /// Wait for the instance to go idle, either through finishing all commands or shutting down/crashing.
+ ///
+ ///
+ /// If true, throw an exception if the server dies on an unhandled exception.
+ ///
+ ///
+ ///
+ /// Thrown if is true and the instance shuts down on an unhandled exception.
+ ///
+ Task WaitIdleAsync(bool throwOnUnhandled = true, CancellationToken cancellationToken = default);
+
+ ///
+ /// Queue for the server to run n ticks.
+ ///
+ /// The amount of ticks to run.
+ void RunTicks(int ticks);
+
+ ///
+ /// followed by
+ ///
+ Task WaitRunTicks(int ticks);
+
+ ///
+ /// Queue for a delegate to be ran inside the main loop of the instance.
+ ///
+ ///
+ /// Do not run NUnit assertions inside . Use instead.
+ ///
+ void Post(Action post);
+
+ ///
+ Task WaitPost(Action post);
+
+ ///
+ /// Queue for a delegate to be ran inside the main loop of the instance,
+ /// rethrowing any exceptions in .
+ ///
+ ///
+ /// Exceptions raised inside this callback will be rethrown by .
+ /// This makes it ideal for NUnit assertions,
+ /// since rethrowing the NUnit assertion directly provides less noise.
+ ///
+ void Assert(Action assertion);
+
+ ///
+ Task WaitAssertion(Action assertion);
+
+ ///
+ /// Post-test cleanup
+ ///
+ Task Cleanup();
+}
+
+public interface IClientIntegrationInstance : IIntegrationInstance
+{
+ IClientNetManager CNetMan => (IClientNetManager) NetMan;
+ ICommonSession? Session { get; }
+ NetUserId? User { get; }
+ EntityUid? AttachedEntity { get; }
+ Task Connect(IServerIntegrationInstance target);
+}
+
+public interface IServerIntegrationInstance : IIntegrationInstance;
diff --git a/Robust.UnitTesting/Pool/ITestPair.cs b/Robust.UnitTesting/Pool/ITestPair.cs
new file mode 100644
index 000000000..57eaca596
--- /dev/null
+++ b/Robust.UnitTesting/Pool/ITestPair.cs
@@ -0,0 +1,39 @@
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
+using Robust.Shared.Timing;
+
+namespace Robust.UnitTesting.Pool;
+
+public interface ITestPair
+{
+ int Id { get; }
+ public Stopwatch Watch { get; }
+ public PairState State { get; }
+ public bool Initialized { get; }
+ void Kill();
+ List TestHistory { get; }
+ PairSettings Settings { get; set; }
+
+ int ServerSeed { get; }
+ int ClientSeed { get; }
+
+ void ActivateContext(TextWriter testOut);
+ void ValidateSettings(PairSettings settings);
+ void SetupSeed();
+ void ClearModifiedCvars();
+ void Use();
+ Task Init(int id, BasePoolManager manager, PairSettings settings, TextWriter testOut);
+ Task RecycleInternal(PairSettings next, TextWriter testOut);
+ Task ApplySettings(PairSettings settings);
+ Task RunTicksSync(int ticks);
+ Task SyncTicks(int targetDelta = 1);
+}
+
+public enum PairState : byte
+{
+ Ready = 0,
+ InUse = 1,
+ CleanDisposed = 2,
+ Dead = 3,
+}
diff --git a/Robust.UnitTesting/Pool/PairSettings.cs b/Robust.UnitTesting/Pool/PairSettings.cs
new file mode 100644
index 000000000..96a02a765
--- /dev/null
+++ b/Robust.UnitTesting/Pool/PairSettings.cs
@@ -0,0 +1,95 @@
+using System;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+
+namespace Robust.UnitTesting.Pool;
+
+///
+/// Settings for a server-client pair. These settings may change over a pair's lifetime.
+/// The pool manager handles fetching pairs with a given setting, including applying new settings to re-used pairs.
+///
+[Virtual]
+public class PairSettings
+{
+ ///
+ /// Set to true if the test will ruin the server/client pair.
+ ///
+ public virtual bool Destructive { get; init; }
+
+ ///
+ /// Set to true if the given server/client pair should be created fresh.
+ ///
+ public virtual bool Fresh { get; init; }
+
+ ///
+ /// Set to true if the given server/client pair should be connected from each other.
+ /// Defaults to disconnected as it makes dirty recycling slightly faster.
+ ///
+ public virtual bool Connected { get; init; }
+
+ ///
+ /// This will return a server-client pair that has not loaded test prototypes.
+ /// Try avoiding this whenever possible, as this will always create & destroy a new pair.
+ /// Use if you need to
+ /// exclude test prototypes.
+ ///
+ public virtual bool NoLoadTestPrototypes { get; init; }
+
+ ///
+ /// Set this to true to disable the NetInterp CVar on the given server/client pair
+ ///
+ public virtual bool DisableInterpolate { get; init; }
+
+ ///
+ /// Set this to true to always clean up the server/client pair before giving it to another borrower
+ ///
+ public virtual bool Dirty { get; init; }
+
+ ///
+ /// Overrides the test name detection, and uses this in the test history instead
+ ///
+ public virtual string? TestName { get; set; }
+
+ ///
+ /// If set, this will be used to call
+ ///
+ public virtual int? ServerSeed { get; set; }
+
+ ///
+ /// If set, this will be used to call
+ ///
+ public virtual int? ClientSeed { get; set; }
+
+ #region Inferred Properties
+
+ ///
+ /// If the returned pair must not be reused
+ ///
+ public virtual bool MustNotBeReused => Destructive || NoLoadTestPrototypes;
+
+ ///
+ /// If the given pair must be brand new
+ ///
+ public virtual bool MustBeNew => Fresh || NoLoadTestPrototypes;
+
+ #endregion
+
+ ///
+ /// Tries to guess if we can skip recycling the server/client pair.
+ ///
+ /// The next set of settings the old pair will be set to
+ /// If we can skip cleaning it up
+ public virtual bool CanFastRecycle(PairSettings nextSettings)
+ {
+ if (MustNotBeReused)
+ throw new InvalidOperationException("Attempting to recycle a non-reusable test.");
+
+ if (nextSettings.MustBeNew)
+ throw new InvalidOperationException("Attempting to recycle a test while requesting a fresh test.");
+
+ if (Dirty)
+ return false;
+
+ return Connected == nextSettings.Connected;
+ }
+}
diff --git a/Robust.UnitTesting/Pool/PoolManager.cs b/Robust.UnitTesting/Pool/PoolManager.cs
new file mode 100644
index 000000000..572602b6c
--- /dev/null
+++ b/Robust.UnitTesting/Pool/PoolManager.cs
@@ -0,0 +1,332 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Reflection;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Robust.Shared;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+namespace Robust.UnitTesting.Pool;
+
+public abstract class BasePoolManager
+{
+ internal abstract void Return(ITestPair pair);
+ public abstract Assembly[] ClientAssemblies { get; }
+ public abstract Assembly[] ServerAssemblies { get; }
+ public readonly List TestPrototypes = new();
+
+ // default cvar overrides to use when creating test pairs.
+ public readonly List<(string cvar, string value)> DefaultCvars =
+ [
+ (CVars.NetPVS.Name, "false"),
+ (CVars.ThreadParallelCount.Name, "1"),
+ (CVars.ReplayClientRecordingEnabled.Name, "false"),
+ (CVars.ReplayServerRecordingEnabled.Name, "false"),
+ (CVars.NetBufferSize.Name, "0")
+ ];
+}
+
+[Virtual]
+public class PoolManager : BasePoolManager where TPair : class, ITestPair, new()
+{
+ private int _nextPairId;
+ private readonly Lock _pairLock = new();
+ private bool _initialized;
+
+ ///
+ /// Set of all pairs, and whether they are currently in-use
+ ///
+ protected readonly Dictionary Pairs = new();
+ private bool _dead;
+ private Exception? _poolFailureReason;
+
+ private Assembly[] _clientAssemblies = [];
+ private Assembly[] _serverAssemblies = [];
+
+ public override Assembly[] ClientAssemblies => _clientAssemblies;
+ public override Assembly[] ServerAssemblies => _serverAssemblies;
+
+ ///
+ /// Initialize the pool manager. Override this to configure what assemblies should get loaded.
+ ///
+ public virtual void Startup(params Assembly[] extraAssemblies)
+ {
+ // By default, load no content assemblies, but make both server & client load the testing assembly.
+ Startup([], [], extraAssemblies);
+ }
+
+ protected void Startup(Assembly[] clientAssemblies, Assembly[] serverAssemblies, Assembly[] sharedAssemblies)
+ {
+ if (_initialized)
+ throw new InvalidOperationException("Already initialized");
+
+ DebugTools.AssertEqual(clientAssemblies.Intersect(sharedAssemblies).Count(), 0);
+ DebugTools.AssertEqual(serverAssemblies.Intersect(sharedAssemblies).Count(), 0);
+ DebugTools.AssertEqual(serverAssemblies.Intersect(clientAssemblies).Count(), 0);
+
+ foreach (var assembly in sharedAssemblies)
+ {
+ DiscoverTestPrototypes(assembly);
+ }
+
+ foreach (var assembly in clientAssemblies)
+ {
+ DiscoverTestPrototypes(assembly);
+ }
+
+ foreach (var assembly in serverAssemblies)
+ {
+ DiscoverTestPrototypes(assembly);
+ }
+
+ _initialized = true;
+ _clientAssemblies = clientAssemblies.Concat(sharedAssemblies).ToArray();
+ _serverAssemblies = serverAssemblies.Concat(sharedAssemblies).ToArray();
+ }
+
+ ///
+ /// This shuts down the pool, and disposes all the server/client pairs.
+ /// This is a one time operation to be used when the testing program is exiting.
+ ///
+ public void Shutdown()
+ {
+ List localPairs;
+ lock (_pairLock)
+ {
+ if (_dead)
+ return;
+ _dead = true;
+ localPairs = Pairs.Keys.ToList();
+ }
+
+ foreach (var pair in localPairs)
+ {
+ pair.Kill();
+ }
+
+ _initialized = false;
+ TestPrototypes.Clear();
+ }
+
+ protected virtual string GetDefaultTestName(TestContext testContext)
+ {
+ return testContext.Test.FullName.Replace("Robust.UnitTesting.", "");
+ }
+
+ public string DeathReport()
+ {
+ lock (_pairLock)
+ {
+ var builder = new StringBuilder();
+ var pairs = Pairs.Keys.OrderBy(pair => pair.Id);
+ foreach (var pair in pairs)
+ {
+ var borrowed = Pairs[pair];
+ builder.AppendLine($"Pair {pair.Id}, Tests Run: {pair.TestHistory.Count}, Borrowed: {borrowed}");
+ for (var i = 0; i < pair.TestHistory.Count; i++)
+ {
+ builder.AppendLine($"#{i}: {pair.TestHistory[i]}");
+ }
+ }
+
+ return builder.ToString();
+ }
+ }
+
+ public virtual PairSettings DefaultSettings => new();
+
+ public async Task GetPair(PairSettings? settings = null)
+ {
+ if (!_initialized)
+ throw new InvalidOperationException($"Pool manager has not been initialized");
+
+ settings ??= DefaultSettings;
+
+ // Trust issues with the AsyncLocal that backs this.
+ var testContext = TestContext.CurrentContext;
+ var testOut = TestContext.Out;
+
+ DieIfPoolFailure();
+ var currentTestName = settings.TestName ?? GetDefaultTestName(testContext);
+ var watch = new Stopwatch();
+ await testOut.WriteLineAsync($"{nameof(GetPair)}: Called by test {currentTestName}");
+ TPair? pair = null;
+ try
+ {
+ watch.Start();
+ if (settings.MustBeNew)
+ {
+ await testOut.WriteLineAsync(
+ $"{nameof(GetPair)}: Creating pair, because settings of pool settings");
+ pair = await CreateServerClientPair(settings, testOut);
+ }
+ else
+ {
+ await testOut.WriteLineAsync($"{nameof(GetPair)}: Looking in pool for a suitable pair");
+ pair = GrabOptimalPair(settings);
+ if (pair != null)
+ {
+ pair.ActivateContext(testOut);
+ await testOut.WriteLineAsync($"{nameof(GetPair)}: Suitable pair found");
+
+ if (pair.Settings.CanFastRecycle(settings))
+ {
+ await testOut.WriteLineAsync($"{nameof(GetPair)}: Cleanup not needed, Skipping cleanup of pair");
+ await pair.ApplySettings(settings);
+ }
+ else
+ {
+ await testOut.WriteLineAsync($"{nameof(GetPair)}: Cleaning existing pair");
+ await pair.RecycleInternal(settings, testOut);
+ }
+
+ await pair.RunTicksSync(5);
+ await pair.SyncTicks(targetDelta: 1);
+ }
+ else
+ {
+ await testOut.WriteLineAsync($"{nameof(GetPair)}: Creating a new pair, no suitable pair found in pool");
+ pair = await CreateServerClientPair(settings, testOut);
+ }
+ }
+ }
+ finally
+ {
+ if (pair != null && pair.TestHistory.Count > 0)
+ {
+ await testOut.WriteLineAsync($"{nameof(GetPair)}: Pair {pair.Id} Test History Start");
+ for (var i = 0; i < pair.TestHistory.Count; i++)
+ {
+ await testOut.WriteLineAsync($"- Pair {pair.Id} Test #{i}: {pair.TestHistory[i]}");
+ }
+ await testOut.WriteLineAsync($"{nameof(GetPair)}: Pair {pair.Id} Test History End");
+ }
+ }
+
+ await testOut.WriteLineAsync($"{nameof(GetPair)}: Retrieving pair {pair.Id} from pool took {watch.Elapsed.TotalMilliseconds} ms");
+
+ pair.ValidateSettings(settings);
+ pair.ClearModifiedCvars();
+ pair.Settings = settings;
+ pair.TestHistory.Add(currentTestName);
+ pair.SetupSeed();
+
+ await testOut.WriteLineAsync($"{nameof(GetPair)}: Returning pair {pair.Id} with client/server seeds: {pair.ClientSeed}/{pair.ServerSeed}");
+
+ pair.Watch.Restart();
+ return pair;
+ }
+
+ private TPair? GrabOptimalPair(PairSettings poolSettings)
+ {
+ lock (_pairLock)
+ {
+ TPair? fallback = null;
+ foreach (var pair in Pairs.Keys)
+ {
+ if (Pairs[pair])
+ continue;
+
+ if (!pair.Settings.CanFastRecycle(poolSettings))
+ {
+ fallback = pair;
+ continue;
+ }
+
+ pair.Use();
+ Pairs[pair] = true;
+ return pair;
+ }
+
+ if (fallback == null)
+ return null;
+
+ fallback.Use();
+ Pairs[fallback!] = true;
+ return fallback;
+ }
+ }
+
+ ///
+ /// Used by TestPair after checking the server/client pair, Don't use this.
+ ///
+ internal override void Return(ITestPair pair)
+ {
+ lock (_pairLock)
+ {
+ if (pair.State == PairState.Dead)
+ Pairs.Remove((TPair)pair);
+ else if (pair.State == PairState.Ready)
+ Pairs[(TPair) pair] = false;
+ else
+ throw new InvalidOperationException($"Attempted to return a pair in an invalid state. Pair: {pair.Id}. State: {pair.State}.");
+ }
+ }
+
+ private void DieIfPoolFailure()
+ {
+ if (_poolFailureReason != null)
+ {
+ // If the _poolFailureReason is not null, we can assume at least one test failed.
+ // So we say inconclusive so we don't add more failed tests to search through.
+ Assert.Inconclusive(@$"
+In a different test, the pool manager had an exception when trying to create a server/client pair.
+Instead of risking that the pool manager will fail at creating a server/client pairs for every single test,
+we are just going to end this here to save a lot of time. This is the exception that started this:\n {_poolFailureReason}");
+ }
+
+ if (_dead)
+ {
+ // If Pairs is null, we ran out of time, we can't assume a test failed.
+ // So we are going to tell it all future tests are a failure.
+ Assert.Fail("The pool was shut down");
+ }
+ }
+
+ private async Task CreateServerClientPair(PairSettings settings, TextWriter testOut)
+ {
+ try
+ {
+ var id = Interlocked.Increment(ref _nextPairId);
+ var pair = new TPair();
+ await pair.Init(id, this, settings, testOut);
+ pair.Use();
+ await pair.RunTicksSync(5);
+ await pair.SyncTicks(targetDelta: 1);
+ return pair;
+ }
+ catch (Exception ex)
+ {
+ _poolFailureReason = ex;
+ throw;
+ }
+ }
+
+ private void DiscoverTestPrototypes(Assembly assembly)
+ {
+ const BindingFlags flags = BindingFlags.Static
+ | BindingFlags.NonPublic
+ | BindingFlags.Public
+ | BindingFlags.DeclaredOnly;
+
+ foreach (var type in assembly.GetTypes())
+ {
+ foreach (var field in type.GetFields(flags))
+ {
+ if (!field.HasCustomAttribute())
+ continue;
+
+ var val = field.GetValue(null);
+ if (val is not string str)
+ throw new Exception($"{nameof(TestPrototypesAttribute)} is only valid on non-null string fields");
+
+ TestPrototypes.Add(str);
+ }
+ }
+ }
+}
diff --git a/Robust.UnitTesting/Pool/PoolTestLogHandler.cs b/Robust.UnitTesting/Pool/PoolTestLogHandler.cs
new file mode 100644
index 000000000..202314e43
--- /dev/null
+++ b/Robust.UnitTesting/Pool/PoolTestLogHandler.cs
@@ -0,0 +1,77 @@
+using System;
+using System.IO;
+using NUnit.Framework;
+using Robust.Shared.Log;
+using Robust.Shared.Timing;
+using Serilog.Events;
+
+namespace Robust.UnitTesting.Pool;
+
+///
+/// Log handler intended for pooled integration tests.
+///
+///
+///
+/// This class logs to two places: an NUnit (so it nicely gets attributed to a test in your IDE),
+/// and an in-memory ring buffer for diagnostic purposes. If test pooling breaks, the ring buffer can be used to see what the broken instance has gone through.
+///
+///
+/// The active test context can be swapped out so pooled instances can correctly have their logs attributed.
+///
+///
+public sealed class PoolTestLogHandler : ILogHandler
+{
+ private readonly string? _prefix;
+
+ private RStopwatch _stopwatch;
+
+ public TextWriter? ActiveContext { get; private set; }
+
+ public LogLevel? FailureLevel { get; set; }
+
+ public PoolTestLogHandler(string? prefix)
+ {
+ _prefix = prefix != null ? $"{prefix}: " : "";
+ }
+
+ public bool ShuttingDown;
+
+ public void Log(string sawmillName, LogEvent message)
+ {
+ var level = message.Level.ToRobust();
+
+ if (ShuttingDown && (FailureLevel == null || level < FailureLevel))
+ return;
+
+ if (ActiveContext is not { } testContext)
+ {
+ // If this gets hit it means something is logging to this instance while it's "between" tests.
+ // This is a bug in either the game or the testing system, and must always be investigated.
+ throw new InvalidOperationException("Log to pool test log handler without active test context");
+ }
+
+ var name = LogMessage.LogLevelToName(level);
+ var seconds = _stopwatch.Elapsed.TotalSeconds;
+ var rendered = message.RenderMessage();
+ var line = $"{_prefix}{seconds:F3}s [{name}] {sawmillName}: {rendered}";
+
+ testContext.WriteLine(line);
+
+ if (FailureLevel == null || level < FailureLevel)
+ return;
+
+ testContext.Flush();
+ Assert.Fail($"{line} Exception: {message.Exception}");
+ }
+
+ public void ClearContext()
+ {
+ ActiveContext = null;
+ }
+
+ public void ActivateContext(TextWriter context)
+ {
+ _stopwatch.Restart();
+ ActiveContext = context;
+ }
+}
diff --git a/Robust.UnitTesting/Pool/TestMapData.cs b/Robust.UnitTesting/Pool/TestMapData.cs
new file mode 100644
index 000000000..269acc5da
--- /dev/null
+++ b/Robust.UnitTesting/Pool/TestMapData.cs
@@ -0,0 +1,23 @@
+using Robust.Shared.GameObjects;
+using Robust.Shared.Map;
+using Robust.Shared.Map.Components;
+
+namespace Robust.UnitTesting.Pool;
+
+///
+/// Simple data class that stored information about a map being used by a test.
+///
+public sealed class TestMapData
+{
+ public EntityUid MapUid { get; set; }
+ public Entity Grid;
+ public MapId MapId;
+ public EntityCoordinates GridCoords { get; set; }
+ public MapCoordinates MapCoords { get; set; }
+ public TileRef Tile { get; set; }
+
+ // Client-side uids
+ public EntityUid CMapUid { get; set; }
+ public EntityUid CGridUid { get; set; }
+ public EntityCoordinates CGridCoords { get; set; }
+}
diff --git a/Robust.UnitTesting/Pool/TestPair.Helpers.cs b/Robust.UnitTesting/Pool/TestPair.Helpers.cs
new file mode 100644
index 000000000..cb5fc5928
--- /dev/null
+++ b/Robust.UnitTesting/Pool/TestPair.Helpers.cs
@@ -0,0 +1,285 @@
+using System;
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using System.Linq;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Map;
+using Robust.Shared.Prototypes;
+
+namespace Robust.UnitTesting.Pool;
+
+// This partial file contains misc helper functions to make writing tests easier.
+public partial class TestPair
+{
+ ///
+ /// Convert a client-side uid into a server-side uid
+ ///
+ public EntityUid ToServerUid(EntityUid uid) => ConvertUid(uid, Client, Server);
+
+ ///
+ /// Convert a server-side uid into a client-side uid
+ ///
+ public EntityUid ToClientUid(EntityUid uid) => ConvertUid(uid, Server, Client);
+
+ private static EntityUid ConvertUid(EntityUid uid, IIntegrationInstance source, IIntegrationInstance destination)
+ {
+ if (!uid.IsValid())
+ return EntityUid.Invalid;
+
+ if (!source.EntMan.TryGetComponent(uid, out var meta))
+ {
+ Assert.Fail($"Failed to resolve MetaData while converting the EntityUid for entity {uid}");
+ return EntityUid.Invalid;
+ }
+
+ if (!destination.EntMan.TryGetEntity(meta.NetEntity, out var otherUid))
+ {
+ Assert.Fail($"Failed to resolve net ID while converting the EntityUid entity {source.EntMan.ToPrettyString(uid)}");
+ return EntityUid.Invalid;
+ }
+
+ return otherUid.Value;
+ }
+
+ ///
+ /// Execute a command on the server and wait some number of ticks.
+ ///
+ public async Task WaitCommand(string cmd, int numTicks = 10)
+ {
+ await Server.ExecuteCommand(cmd);
+ await RunTicksSync(numTicks);
+ }
+
+ ///
+ /// Execute a command on the client and wait some number of ticks.
+ ///
+ public async Task WaitClientCommand(string cmd, int numTicks = 10)
+ {
+ await Client.ExecuteCommand(cmd);
+ await RunTicksSync(numTicks);
+ }
+
+ ///
+ /// Retrieve all entity prototypes that have some component.
+ ///
+ public List<(EntityPrototype, T)> GetPrototypesWithComponent(
+ HashSet? ignored = null,
+ bool ignoreAbstract = true,
+ bool ignoreTestPrototypes = true)
+ where T : IComponent, new()
+ {
+ if (!Server.Resolve().TryGetRegistration(out var reg)
+ && !Client.Resolve().TryGetRegistration(out reg))
+ {
+ Assert.Fail($"Unknown component: {typeof(T).Name}");
+ return new();
+ }
+
+ var id = reg.Name;
+ var list = new List<(EntityPrototype, T)>();
+ foreach (var proto in Server.ProtoMan.EnumeratePrototypes())
+ {
+ if (ignored != null && ignored.Contains(proto.ID))
+ continue;
+
+ if (ignoreAbstract && proto.Abstract)
+ continue;
+
+ if (ignoreTestPrototypes && IsTestPrototype(proto))
+ continue;
+
+ if (proto.Components.TryGetComponent(id, out var cmp))
+ list.Add((proto, (T)cmp));
+ }
+
+ return list;
+ }
+
+ ///
+ /// Retrieve all entity prototypes that have some component.
+ ///
+ public List GetPrototypesWithComponent(
+ Type type,
+ HashSet? ignored = null,
+ bool ignoreAbstract = true,
+ bool ignoreTestPrototypes = true)
+ {
+ if (!Server.Resolve().TryGetRegistration(type, out var reg)
+ && !Client.Resolve().TryGetRegistration(type, out reg))
+ {
+ Assert.Fail($"Unknown component: {type.Name}");
+ return new();
+ }
+
+ var id = reg.Name;
+ var list = new List();
+ foreach (var proto in Server.ProtoMan.EnumeratePrototypes())
+ {
+ if (ignored != null && ignored.Contains(proto.ID))
+ continue;
+
+ if (ignoreAbstract && proto.Abstract)
+ continue;
+
+ if (ignoreTestPrototypes && IsTestPrototype(proto))
+ continue;
+
+ if (proto.Components.ContainsKey(id))
+ list.Add((proto));
+ }
+
+ return list;
+ }
+
+ public async Task Connect()
+ {
+ Assert.That(Client.NetMan.IsConnected, Is.False);
+ await Client.Connect(Server);
+ await ReallyBeIdle(10);
+ await Client.WaitRunTicks(1);
+ }
+
+ public async Task Disconnect(string reason = "")
+ {
+ await Client.WaitPost(() => Client.CNetMan.ClientDisconnect(reason));
+ await ReallyBeIdle(10);
+ }
+
+ public bool IsTestPrototype(EntityPrototype proto)
+ {
+ return _loadedEntityPrototypes.Contains(proto.ID);
+ }
+
+ public bool IsTestEntityPrototype(string id)
+ {
+ return _loadedEntityPrototypes.Contains(id);
+ }
+
+ public bool IsTestPrototype(string id) where TPrototype : IPrototype
+ {
+ return IsTestPrototype(typeof(TPrototype), id);
+ }
+
+ public bool IsTestPrototype(TPrototype proto) where TPrototype : IPrototype
+ {
+ return IsTestPrototype(typeof(TPrototype), proto.ID);
+ }
+
+ public bool IsTestPrototype(Type kind, string id)
+ {
+ return _loadedPrototypes.TryGetValue(kind, out var ids) && ids.Contains(id);
+ }
+
+ ///
+ /// Runs the server-client pair in sync
+ ///
+ /// How many ticks to run them for
+ public async Task RunTicksSync(int ticks)
+ {
+ for (var i = 0; i < ticks; i++)
+ {
+ await Server.WaitRunTicks(1);
+ await Client.WaitRunTicks(1);
+ }
+ }
+
+ ///
+ /// Convert a time interval to some number of ticks.
+ ///
+ public int SecondsToTicks(float seconds)
+ {
+ return (int) Math.Ceiling(seconds / Server.Timing.TickPeriod.TotalSeconds);
+ }
+
+ ///
+ /// Run the server & client in sync for some amount of time
+ ///
+ public async Task RunSeconds(float seconds)
+ {
+ await RunTicksSync(SecondsToTicks(seconds));
+ }
+
+ ///
+ /// Runs the server-client pair in sync, but also ensures they are both idle each tick.
+ ///
+ /// How many ticks to run
+ public async Task ReallyBeIdle(int runTicks = 25)
+ {
+ for (var i = 0; i < runTicks; i++)
+ {
+ await Client.WaitRunTicks(1);
+ await Server.WaitRunTicks(1);
+ for (var idleCycles = 0; idleCycles < 4; idleCycles++)
+ {
+ await Client.WaitIdleAsync();
+ await Server.WaitIdleAsync();
+ }
+ }
+ }
+
+ ///
+ /// Run the server/clients until the ticks are synchronized.
+ /// By default the client will be one tick ahead of the server.
+ ///
+ public async Task SyncTicks(int targetDelta = 1)
+ {
+ var sTick = (int)Server.Timing.CurTick.Value;
+ var cTick = (int)Client.Timing.CurTick.Value;
+ var delta = cTick - sTick;
+
+ if (delta == targetDelta)
+ return;
+ if (delta > targetDelta)
+ await Server.WaitRunTicks(delta - targetDelta);
+ else
+ await Client.WaitRunTicks(targetDelta - delta);
+
+ sTick = (int)Server.Timing.CurTick.Value;
+ cTick = (int)Client.Timing.CurTick.Value;
+ delta = cTick - sTick;
+ Assert.That(delta, Is.EqualTo(targetDelta));
+ }
+
+ ///
+ /// Creates a map with a single grid consisting of one tile.
+ ///
+ [MemberNotNull(nameof(TestMap))]
+ public async Task CreateTestMap(bool initialized, ushort tileTypeId)
+ {
+ TestMap = new TestMapData();
+ await Server.WaitIdleAsync();
+ var sys = Server.System();
+
+ await Server.WaitPost(() =>
+ {
+ TestMap.MapUid = sys.CreateMap(out TestMap.MapId, runMapInit: initialized);
+ TestMap.Grid = Server.MapMan.CreateGridEntity(TestMap.MapId);
+ TestMap.GridCoords = new EntityCoordinates(TestMap.Grid, 0, 0);
+ TestMap.MapCoords = new MapCoordinates(0, 0, TestMap.MapId);
+ sys.SetTile(TestMap.Grid.Owner, TestMap.Grid.Comp, TestMap.GridCoords, new Tile(tileTypeId));
+ TestMap.Tile = sys.GetAllTiles(TestMap.Grid.Owner, TestMap.Grid.Comp).First();
+ });
+
+ if (!Settings.Connected)
+ return TestMap;
+
+ await RunTicksSync(10);
+ TestMap.CMapUid = ToClientUid(TestMap.MapUid);
+ TestMap.CGridUid = ToClientUid(TestMap.Grid);
+ TestMap.CGridCoords = new EntityCoordinates(TestMap.CGridUid, 0, 0);
+
+ return TestMap;
+ }
+
+ ///
+ [MemberNotNull(nameof(TestMap))]
+ public async Task CreateTestMap(bool initialized, string tileName)
+ {
+ var defMan = Server.Resolve();
+ if (!defMan.TryGetDefinition(tileName, out var def))
+ Assert.Fail($"Unknown tile: {tileName}");
+ return await CreateTestMap(initialized, def?.TileId ?? 1);
+ }
+}
diff --git a/Robust.UnitTesting/Pool/TestPair.Recycle.cs b/Robust.UnitTesting/Pool/TestPair.Recycle.cs
new file mode 100644
index 000000000..1fe2b0bcb
--- /dev/null
+++ b/Robust.UnitTesting/Pool/TestPair.Recycle.cs
@@ -0,0 +1,208 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Robust.Client;
+using Robust.Shared;
+using Robust.Shared.Exceptions;
+using Robust.Shared.Network;
+using Robust.Shared.Player;
+
+namespace Robust.UnitTesting.Pool;
+
+// This partial file contains logic related to recycling & disposing test pairs.
+public partial class TestPair
+{
+ private async Task OnDirtyDispose()
+ {
+ var usageTime = Watch.Elapsed;
+ Watch.Restart();
+ await TestOut.WriteLineAsync($"{nameof(DisposeAsync)}: Test gave back pair {Id} in {usageTime.TotalMilliseconds} ms");
+ Kill();
+ var disposeTime = Watch.Elapsed;
+ await TestOut.WriteLineAsync($"{nameof(DisposeAsync)}: Disposed pair {Id} in {disposeTime.TotalMilliseconds} ms");
+ // Test pairs should only dirty dispose if they are failing. If they are not failing, this probably happened
+ // because someone forgot to clean-return the pair.
+ Assert.Warn("Test was dirty-disposed.");
+ }
+
+ ///
+ /// This method gets called before any test pair gets returned to the pool.
+ ///
+ protected virtual async Task Cleanup()
+ {
+ if (TestMap != null)
+ {
+ await Server.WaitPost(() => Server.EntMan.DeleteEntity(TestMap.MapUid));
+ TestMap = null;
+ }
+ }
+
+ private async Task OnCleanDispose()
+ {
+ await Server.WaitIdleAsync();
+ await Client.WaitIdleAsync();
+ await Cleanup();
+ await Server.Cleanup();
+ await Client.Cleanup();
+ await RevertModifiedCvars();
+
+ var usageTime = Watch.Elapsed;
+ Watch.Restart();
+ await TestOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: Test borrowed pair {Id} for {usageTime.TotalMilliseconds} ms");
+ // Let any last minute failures the test cause happen.
+ await ReallyBeIdle();
+ if (!Settings.Destructive)
+ {
+ if (Client.IsAlive == false)
+ throw new Exception($"{nameof(CleanReturnAsync)}: Test killed the client in pair {Id}:", Client.UnhandledException);
+
+ if (Server.IsAlive == false)
+ throw new Exception($"{nameof(CleanReturnAsync)}: Test killed the server in pair {Id}:", Server.UnhandledException);
+ }
+
+ if (Settings.MustNotBeReused)
+ {
+ Kill();
+ await ReallyBeIdle();
+ await TestOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: Clean disposed in {Watch.Elapsed.TotalMilliseconds} ms");
+ return;
+ }
+
+ var sRuntimeLog = Server.Resolve();
+ if (sRuntimeLog.ExceptionCount > 0)
+ throw new Exception($"{nameof(CleanReturnAsync)}: Server logged exceptions");
+ var cRuntimeLog = Client.Resolve();
+ if (cRuntimeLog.ExceptionCount > 0)
+ throw new Exception($"{nameof(CleanReturnAsync)}: Client logged exceptions");
+
+ var returnTime = Watch.Elapsed;
+ await TestOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: PoolManager took {returnTime.TotalMilliseconds} ms to put pair {Id} back into the pool");
+ }
+
+ public async ValueTask CleanReturnAsync()
+ {
+ if (State != PairState.InUse)
+ throw new Exception($"{nameof(CleanReturnAsync)}: Unexpected state. Pair: {Id}. State: {State}.");
+
+ await TestOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: Return of pair {Id} started");
+ State = PairState.CleanDisposed;
+ await OnCleanDispose();
+ State = PairState.Ready;
+ Manager.Return(this);
+ ClearContext();
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ switch (State)
+ {
+ case PairState.Dead:
+ case PairState.Ready:
+ break;
+ case PairState.InUse:
+ await TestOut.WriteLineAsync($"{nameof(DisposeAsync)}: Dirty return of pair {Id} started");
+ await OnDirtyDispose();
+ Manager.Return(this);
+ ClearContext();
+ break;
+ default:
+ throw new Exception($"{nameof(DisposeAsync)}: Unexpected state. Pair: {Id}. State: {State}.");
+ }
+ }
+
+ ///
+ /// This method gets called when a previously used test pair is being retrieved from the pool.
+ /// Note that in some instances this method may get skipped (See ).
+ ///
+ public async Task RecycleInternal(PairSettings settings, TextWriter testOut)
+ {
+ Watch.Restart();
+ await testOut.WriteLineAsync($"Recycling...");
+ await RunTicksSync(1);
+
+ // Disconnect the client if they are connected.
+ if (Client.CNetMan.IsConnected)
+ {
+ await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Disconnecting client.");
+ await Client.WaitPost(() => Client.CNetMan.ClientDisconnect("Test pooling cleanup disconnect"));
+ await RunTicksSync(1);
+ }
+
+ await Recycle(settings, testOut);
+ ClearModifiedCvars();
+
+ // (possibly) reconnect the client
+ if (settings.Connected)
+ {
+ await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Connecting client");
+ await Client.Connect(Server);
+ }
+
+ Settings = default!;
+ await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Idling");
+ await ReallyBeIdle();
+ await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Done recycling");
+ }
+
+ ///
+ /// This method gets called when a previously used test pair is being retrieved from the pool.
+ /// If the next settings are compatible with the previous settings, this step may get skipped (See ).
+ /// In general, this method should also call .
+ ///
+ protected virtual async Task Recycle(PairSettings next, TextWriter testOut)
+ {
+ //Apply Cvars
+ await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Setting CVar ");
+ await ApplySettings(next);
+ await RunTicksSync(1);
+
+ // flush server entities.
+ await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Flushing server entities");
+ await Server.WaitPost(() => Server.EntMan.FlushEntities());
+ await RunTicksSync(1);
+ }
+
+ ///
+ /// Apply settings to the test pair. This method is always called when a pair is fetched from the pool. There should
+ /// be no need to apply settings that require a pair to be recycled, as in those cases the
+ /// should have caused to be invoked, which should
+ /// already have applied those settings.
+ ///
+ public async Task ApplySettings(PairSettings next)
+ {
+ await ApplySettings(Client, next);
+ await ApplySettings(Server, next);
+ }
+
+ ///
+ [MustCallBase]
+ protected internal virtual async Task ApplySettings(IIntegrationInstance instance, PairSettings next)
+ {
+ if (instance.CfgMan.IsCVarRegistered(CVars.NetInterp.Name))
+ await instance.WaitPost(() => instance.CfgMan.SetCVar(CVars.NetInterp, !next.DisableInterpolate));
+ }
+
+ ///
+ /// Invoked after a test pair has been recycled to validate that the settings have been properly applied.
+ ///
+ [MustCallBase]
+ public virtual void ValidateSettings(PairSettings settings)
+ {
+ var netMan = Client.Resolve();
+ Assert.That(netMan.IsConnected, Is.EqualTo(settings.Connected));
+
+ if (!settings.Connected)
+ return;
+
+ var baseClient = Client.Resolve();
+ var cPlayer = Client.Resolve();
+ var sPlayer = Server.Resolve();
+
+ Assert.That(baseClient.RunLevel, Is.EqualTo(ClientRunLevel.InGame));
+ Assert.That(sPlayer.Sessions.Length, Is.EqualTo(1));
+ var session = sPlayer.Sessions.Single();
+ Assert.That(cPlayer.LocalSession?.UserId, Is.EqualTo(session.UserId));
+ }
+}
diff --git a/Robust.UnitTesting/Pool/TestPair.cs b/Robust.UnitTesting/Pool/TestPair.cs
new file mode 100644
index 000000000..0ccd0e95b
--- /dev/null
+++ b/Robust.UnitTesting/Pool/TestPair.cs
@@ -0,0 +1,244 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
+using Robust.Shared.Configuration;
+using Robust.Shared.Player;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+using Robust.Shared.Timing;
+using Robust.Shared.Utility;
+
+namespace Robust.UnitTesting.Pool;
+
+///
+/// This object wraps a pooled server+client pair.
+///
+public abstract partial class TestPair : ITestPair, IAsyncDisposable
+ where TServer : IServerIntegrationInstance
+ where TClient : IClientIntegrationInstance
+{
+ public int Id { get; internal set; }
+ protected BasePoolManager Manager = default!;
+ public PairState State { get; private set; } = PairState.Ready;
+ public bool Initialized { get; private set; }
+ protected TextWriter TestOut = default!;
+ public Stopwatch Watch { get; } = new();
+ public List TestHistory { get; } = new();
+ public PairSettings Settings { get; set; } = default!;
+
+ public readonly PoolTestLogHandler ServerLogHandler = new("SERVER");
+ public readonly PoolTestLogHandler ClientLogHandler = new("CLIENT");
+ public TestMapData? TestMap;
+
+ private int _nextServerSeed;
+ private int _nextClientSeed;
+
+ public int ServerSeed { get; set; }
+ public int ClientSeed { get; set; }
+
+ public TServer Server { get; private set; } = default!;
+ public TClient Client { get; private set; } = default!;
+
+ public ICommonSession? Player => Server.PlayerMan.SessionsDict.GetValueOrDefault(Client.User ?? default);
+
+ private Dictionary> _loadedPrototypes = new();
+ private HashSet _loadedEntityPrototypes = new();
+ protected readonly Dictionary ModifiedClientCvars = new();
+ protected readonly Dictionary ModifiedServerCvars = new();
+
+ public async Task LoadPrototypes(List prototypes)
+ {
+ await LoadPrototypes(Server, prototypes);
+ await LoadPrototypes(Client, prototypes);
+ }
+
+ public async Task Init(
+ int id,
+ BasePoolManager manager,
+ PairSettings settings,
+ TextWriter testOut)
+ {
+ if (Initialized)
+ throw new InvalidOperationException("Already initialized");
+
+ Id = id;
+ Manager = manager;
+ Settings = settings;
+ Initialized = true;
+
+ ClientLogHandler.ActivateContext(testOut);
+ ServerLogHandler.ActivateContext(testOut);
+ Client = await GenerateClient();
+ Server = await GenerateServer();
+ ActivateContext(testOut);
+ await ApplySettings(settings);
+
+ Client.CfgMan.OnCVarValueChanged += OnClientCvarChanged;
+ Server.CfgMan.OnCVarValueChanged += OnServerCvarChanged;
+
+ if (!settings.NoLoadTestPrototypes)
+ await LoadPrototypes(Manager.TestPrototypes);
+
+ var cRand = Client.Resolve();
+ var sRand = Server.Resolve();
+ _nextClientSeed = cRand.Next();
+ _nextServerSeed = sRand.Next();
+
+ await Initialize();
+
+ // Always initially connect clients.
+ // This is done in case the server does randomization when client first connects
+ // This is to try and prevent issues where if the first test that connects the client is consistently some test
+ // that uses a fixed seed, it would effectively prevent the initial configuration from being randomized.
+ await Connect();
+
+ if (!Settings.Connected)
+ await Disconnect("Initial disconnect");
+ }
+
+ protected virtual Task Initialize()
+ {
+ return Task.CompletedTask;
+ }
+
+ protected abstract Task GenerateClient();
+ protected abstract Task GenerateServer();
+
+ public void Kill()
+ {
+ State = PairState.Dead;
+ ServerLogHandler.ShuttingDown = true;
+ ClientLogHandler.ShuttingDown = true;
+ Server.Dispose();
+ Client.Dispose();
+ }
+
+ private void ClearContext()
+ {
+ TestOut = default!;
+ ServerLogHandler.ClearContext();
+ ClientLogHandler.ClearContext();
+ }
+
+ public void ActivateContext(TextWriter testOut)
+ {
+ TestOut = testOut;
+ ServerLogHandler.ActivateContext(testOut);
+ ClientLogHandler.ActivateContext(testOut);
+ }
+
+ public void Use()
+ {
+ if (State != PairState.Ready)
+ throw new InvalidOperationException($"Pair is not ready to use. State: {State}");
+ State = PairState.InUse;
+ }
+
+ public void SetupSeed()
+ {
+ var sRand = Server.Resolve();
+ if (Settings.ServerSeed is { } severSeed)
+ {
+ ServerSeed = severSeed;
+ sRand.SetSeed(ServerSeed);
+ }
+ else
+ {
+ ServerSeed = _nextServerSeed;
+ sRand.SetSeed(ServerSeed);
+ _nextServerSeed = sRand.Next();
+ }
+
+ var cRand = Client.Resolve();
+ if (Settings.ClientSeed is { } clientSeed)
+ {
+ ClientSeed = clientSeed;
+ cRand.SetSeed(ClientSeed);
+ }
+ else
+ {
+ ClientSeed = _nextClientSeed;
+ cRand.SetSeed(ClientSeed);
+ _nextClientSeed = cRand.Next();
+ }
+ }
+
+ private async Task LoadPrototypes(IIntegrationInstance instance, List prototypes)
+ {
+ var changed = new Dictionary>();
+ foreach (var file in prototypes)
+ {
+ instance.ProtoMan.LoadString(file, changed: changed);
+ }
+
+ await instance.WaitPost(() => instance.ProtoMan.ReloadPrototypes(changed));
+
+ foreach (var (kind, ids) in changed)
+ {
+ _loadedPrototypes.GetOrNew(kind).UnionWith(ids);
+ }
+
+ if (_loadedPrototypes.TryGetValue(typeof(EntityPrototype), out var entIds))
+ _loadedEntityPrototypes.UnionWith(entIds);
+ }
+
+ public void Deconstruct(out TServer server, out TClient client)
+ {
+ server = Server;
+ client = Client;
+ }
+
+ private void OnServerCvarChanged(CVarChangeInfo args)
+ {
+ ModifiedServerCvars.TryAdd(args.Name, args.OldValue);
+ }
+
+ private void OnClientCvarChanged(CVarChangeInfo args)
+ {
+ ModifiedClientCvars.TryAdd(args.Name, args.OldValue);
+ }
+
+ public void ClearModifiedCvars()
+ {
+ ModifiedClientCvars.Clear();
+ ModifiedServerCvars.Clear();
+ }
+
+ ///
+ /// Reverts any cvars that were modified during a test back to their original values.
+ ///
+ public virtual async Task RevertModifiedCvars()
+ {
+ await Server.WaitPost(() =>
+ {
+ foreach (var (name, value) in ModifiedServerCvars)
+ {
+ if (Server.CfgMan.GetCVar(name).Equals(value))
+ continue;
+
+ Server.Log.Info($"Resetting cvar {name} to {value}");
+ Server.CfgMan.SetCVar(name, value);
+ }
+
+ });
+
+ await Client.WaitPost(() =>
+ {
+ foreach (var (name, value) in ModifiedClientCvars)
+ {
+ if (Client.CfgMan.GetCVar(name).Equals(value))
+ continue;
+
+ var flags = Client.CfgMan.GetCVarFlags(name);
+ if (flags.HasFlag(CVar.REPLICATED) && flags.HasFlag(CVar.SERVER))
+ continue;
+
+ Client.Log.Info($"Resetting cvar {name} to {value}");
+ Client.CfgMan.SetCVar(name, value);
+ }
+ });
+
+ ClearModifiedCvars();
+ }
+}
diff --git a/Robust.UnitTesting/Pool/TestPrototypesAttribute.cs b/Robust.UnitTesting/Pool/TestPrototypesAttribute.cs
new file mode 100644
index 000000000..da2434175
--- /dev/null
+++ b/Robust.UnitTesting/Pool/TestPrototypesAttribute.cs
@@ -0,0 +1,13 @@
+using System;
+using JetBrains.Annotations;
+
+namespace Robust.UnitTesting.Pool;
+
+///
+/// Attribute that indicates that a string contains yaml prototype data that should be loaded by integration tests.
+///
+[AttributeUsage(AttributeTargets.Field)]
+[MeansImplicitUse]
+public sealed class TestPrototypesAttribute : Attribute
+{
+}
diff --git a/Robust.UnitTesting/RobustIntegrationTest.TestPair.cs b/Robust.UnitTesting/RobustIntegrationTest.TestPair.cs
new file mode 100644
index 000000000..38c680c27
--- /dev/null
+++ b/Robust.UnitTesting/RobustIntegrationTest.TestPair.cs
@@ -0,0 +1,80 @@
+using System.Threading.Tasks;
+using Robust.Client;
+using Robust.Server;
+using Robust.Shared.Log;
+using Robust.UnitTesting.Pool;
+
+namespace Robust.UnitTesting;
+
+public partial class RobustIntegrationTest
+{
+ ///
+ /// implementation using instances.
+ ///
+ [Virtual]
+ public class TestPair : TestPair
+ {
+ protected override async Task GenerateClient()
+ {
+ var client = new ClientIntegrationInstance(ClientOptions());
+ await client.WaitIdleAsync();
+ client.Resolve().GetSawmill("loc").Level = LogLevel.Error;
+ client.CfgMan.OnValueChanged(RTCVars.FailureLogLevel, value => ClientLogHandler.FailureLevel = value, true);
+ await client.WaitIdleAsync();
+ return client;
+ }
+
+ protected override async Task GenerateServer()
+ {
+ var server = new ServerIntegrationInstance(ServerOptions());
+ await server.WaitIdleAsync();
+ server.Resolve().GetSawmill("loc").Level = LogLevel.Error;
+ server.CfgMan.OnValueChanged(RTCVars.FailureLogLevel, value => ServerLogHandler.FailureLevel = value, true);
+ return server;
+ }
+
+ protected virtual ClientIntegrationOptions ClientOptions()
+ {
+ var options = new ClientIntegrationOptions
+ {
+ ContentAssemblies = Manager.ClientAssemblies,
+ OverrideLogHandler = () => ClientLogHandler
+ };
+
+ options.Options = new()
+ {
+ LoadConfigAndUserData = false,
+ LoadContentResources = false,
+ };
+
+ foreach (var (cvar, value) in Manager.DefaultCvars)
+ {
+ options.CVarOverrides[cvar] = value;
+ }
+
+ return options;
+ }
+
+ protected virtual ServerIntegrationOptions ServerOptions()
+ {
+ var options = new ServerIntegrationOptions
+ {
+ ContentAssemblies = Manager.ServerAssemblies,
+ OverrideLogHandler = () => ServerLogHandler
+ };
+
+ options.Options = new()
+ {
+ LoadConfigAndUserData = false,
+ LoadContentResources = false,
+ };
+
+ foreach (var (cvar, value) in Manager.DefaultCvars)
+ {
+ options.CVarOverrides[cvar] = value;
+ }
+
+ return options;
+ }
+ }
+}
diff --git a/Robust.UnitTesting/RobustIntegrationTest.cs b/Robust.UnitTesting/RobustIntegrationTest.cs
index 17d35c564..b3c6f344f 100644
--- a/Robust.UnitTesting/RobustIntegrationTest.cs
+++ b/Robust.UnitTesting/RobustIntegrationTest.cs
@@ -13,8 +13,6 @@ using Moq;
using NUnit.Framework;
using Robust.Client;
using Robust.Client.Console;
-using Robust.Client.GameStates;
-using Robust.Client.Player;
using Robust.Client.Timing;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.XAML.Proxy;
@@ -33,13 +31,13 @@ using Robust.Shared.Input;
using Robust.Shared.IoC;
using Robust.Shared.Log;
using Robust.Shared.Map;
-using Robust.Shared.Map.Components;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Reflection;
using Robust.Shared.Serialization;
using Robust.Shared.Timing;
+using Robust.UnitTesting.Pool;
using ServerProgram = Robust.Server.Program;
namespace Robust.UnitTesting
@@ -281,7 +279,7 @@ namespace Robust.UnitTesting
/// This method must be used before trying to access any state like ,
/// to prevent race conditions.
///
- public abstract class IntegrationInstance : IDisposable
+ public abstract class IntegrationInstance : IIntegrationInstance
{
private protected Thread? InstanceThread;
private protected IDependencyCollection DependencyCollection = default!;
@@ -305,10 +303,11 @@ namespace Robust.UnitTesting
public virtual IntegrationOptions? Options { get; internal set; }
- public IEntityManager EntMan { get; private set; } = default!;
+ public EntityManager EntMan { get; private set; } = default!;
public IPrototypeManager ProtoMan { get; private set; } = default!;
public IConfigurationManager CfgMan { get; private set; } = default!;
public ISharedPlayerManager PlayerMan { get; private set; } = default!;
+ public INetManager NetMan { get; private set; } = default!;
public IGameTiming Timing { get; private set; } = default!;
public IMapManager MapMan { get; private set; } = default!;
public IConsoleHost ConsoleHost { get; private set; } = default!;
@@ -316,21 +315,26 @@ namespace Robust.UnitTesting
protected virtual void ResolveIoC(IDependencyCollection deps)
{
- EntMan = deps.Resolve();
+ EntMan = deps.Resolve();
ProtoMan = deps.Resolve();
CfgMan = deps.Resolve();
PlayerMan = deps.Resolve();
Timing = deps.Resolve();
+ NetMan = deps.Resolve();
MapMan = deps.Resolve();
ConsoleHost = deps.Resolve();
Log = deps.Resolve().GetSawmill("test");
}
+ [Pure]
public T System() where T : IEntitySystem
{
return EntMan.System();
}
+ [Pure]
+ public T Resolve() => ResolveDependency();
+
public TransformComponent Transform(EntityUid uid)
{
return EntMan.GetComponent(uid);
@@ -352,13 +356,7 @@ namespace Robust.UnitTesting
await WaitPost(() => ConsoleHost.ExecuteCommand(cmd));
}
- ///
- /// Whether the instance is still alive.
- /// "Alive" indicates that it is able to receive and process commands.
- ///
- ///
- /// Thrown if you did not ensure that the instance is idle via first.
- ///
+ ///
public bool IsAlive
{
get
@@ -438,16 +436,7 @@ namespace Robust.UnitTesting
return DependencyCollection.Resolve();
}
- ///
- /// Wait for the instance to go idle, either through finishing all commands or shutting down/crashing.
- ///
- ///
- /// If true, throw an exception if the server dies on an unhandled exception.
- ///
- ///
- ///
- /// Thrown if is true and the instance shuts down on an unhandled exception.
- ///
+ ///
public Task WaitIdleAsync(bool throwOnUnhandled = true, CancellationToken cancellationToken = default)
{
if (Options?.Asynchronous != false)
@@ -558,10 +547,7 @@ namespace Robust.UnitTesting
}
}
- ///
- /// Queue for the server to run n ticks.
- ///
- /// The amount of ticks to run.
+ ///
public void RunTicks(int ticks)
{
_isSurelyIdle = false;
@@ -569,9 +555,7 @@ namespace Robust.UnitTesting
_toInstanceWriter.TryWrite(new RunTicksMessage(ticks, _currentTicksId));
}
- ///
- /// followed by
- ///
+ ///
public async Task WaitRunTicks(int ticks)
{
RunTicks(ticks);
@@ -590,12 +574,7 @@ namespace Robust.UnitTesting
_toInstanceWriter.TryComplete();
}
- ///
- /// Queue for a delegate to be ran inside the main loop of the instance.
- ///
- ///
- /// Do not run NUnit assertions inside . Use instead.
- ///
+ ///
public void Post(Action post)
{
_isSurelyIdle = false;
@@ -609,15 +588,7 @@ namespace Robust.UnitTesting
await WaitIdleAsync();
}
- ///
- /// Queue for a delegate to be ran inside the main loop of the instance,
- /// rethrowing any exceptions in .
- ///
- ///
- /// Exceptions raised inside this callback will be rethrown by .
- /// This makes it ideal for NUnit assertions,
- /// since rethrowing the NUnit assertion directly provides less noise.
- ///
+ ///
public void Assert(Action assertion)
{
_isSurelyIdle = false;
@@ -631,6 +602,8 @@ namespace Robust.UnitTesting
await WaitIdleAsync();
}
+ public virtual Task Cleanup() => Task.CompletedTask;
+
public void Dispose()
{
Stop();
@@ -654,7 +627,7 @@ namespace Robust.UnitTesting
}
}
- public sealed class ServerIntegrationInstance : IntegrationInstance
+ public sealed class ServerIntegrationInstance : IntegrationInstance, IServerIntegrationInstance
{
public ServerIntegrationInstance(ServerIntegrationOptions? options) : base(options)
{
@@ -724,7 +697,9 @@ namespace Robust.UnitTesting
deps.BuildGraph();
//ServerProgram.SetupLogging();
ServerProgram.InitReflectionManager(deps);
- deps.Resolve().LoadAssemblies(typeof(RobustIntegrationTest).Assembly);
+
+ if (Options?.LoadTestAssembly != false)
+ deps.Resolve().LoadAssemblies(typeof(RobustIntegrationTest).Assembly);
var server = DependencyCollection.Resolve();
@@ -858,15 +833,17 @@ namespace Robust.UnitTesting
}
}
+ public override Task Cleanup() => RemoveAllDummySessions();
+
private Dictionary _dummyUsers = new();
private Dictionary _dummySessions = new();
public IReadOnlyDictionary DummyUsers => _dummyUsers;
public IReadOnlyDictionary DummySessions => _dummySessions;
}
- public sealed class ClientIntegrationInstance : IntegrationInstance
+ public sealed class ClientIntegrationInstance : IntegrationInstance, IClientIntegrationInstance
{
- public ICommonSession? Session => ((IPlayerManager) PlayerMan).LocalSession;
+ public ICommonSession? Session => PlayerMan.LocalSession;
public NetUserId? User => Session?.UserId;
public EntityUid? AttachedEntity => Session?.AttachedEntity;
@@ -903,10 +880,10 @@ namespace Robust.UnitTesting
///
/// Wire up the server to connect to when gets called.
///
- public void SetConnectTarget(ServerIntegrationInstance server)
+ public void SetConnectTarget(IServerIntegrationInstance server)
{
var clientNetManager = ResolveDependency();
- var serverNetManager = server.ResolveDependency();
+ var serverNetManager = server.Resolve();
if (!serverNetManager.IsRunning)
{
@@ -916,6 +893,14 @@ namespace Robust.UnitTesting
clientNetManager.NextConnectChannel = serverNetManager.MessageChannelWriter;
}
+ public async Task Connect(IServerIntegrationInstance target)
+ {
+ await WaitIdleAsync();
+ await target.WaitIdleAsync();
+ SetConnectTarget(target);
+ await WaitPost(() => ((IClientNetManager) NetMan).ClientConnect(null!, 0, null!));
+ }
+
public async Task CheckSandboxed(Assembly assembly)
{
await WaitIdleAsync();
@@ -970,7 +955,9 @@ namespace Robust.UnitTesting
deps.BuildGraph();
GameController.RegisterReflection(deps);
- deps.Resolve().LoadAssemblies(typeof(RobustIntegrationTest).Assembly);
+
+ if (Options?.LoadTestAssembly != false)
+ deps.Resolve().LoadAssemblies(typeof(RobustIntegrationTest).Assembly);
var client = DependencyCollection.Resolve();
@@ -1202,6 +1189,8 @@ namespace Robust.UnitTesting
public Action? BeforeStart { get; set; }
public Assembly[]? ContentAssemblies { get; set; }
+ public bool LoadTestAssembly { get; set; } = true;
+
///
/// String containing extra prototypes to load. Contents of the string are treated like a yaml file in the
/// resources folder.