Move TestPair & PoolManager to engine (#5877)

* Engine pool manager

* Move documentation

* Move namespace

* Move TestMapData to engine

* Option to prevent loading test assembly

* release notes

* Rename to avoid conflicts
This commit is contained in:
Leon Friedrich
2025-05-10 21:35:28 +10:00
committed by GitHub
parent c76444a33f
commit c458abdc69
13 changed files with 1556 additions and 52 deletions
+39
View File
@@ -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<string> 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,
}
+95
View File
@@ -0,0 +1,95 @@
using System;
using Robust.Shared.Prototypes;
using Robust.Shared.Random;
namespace Robust.UnitTesting.Pool;
/// <summary>
/// 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.
/// </summary>
[Virtual]
public class PairSettings
{
/// <summary>
/// Set to true if the test will ruin the server/client pair.
/// </summary>
public virtual bool Destructive { get; init; }
/// <summary>
/// Set to true if the given server/client pair should be created fresh.
/// </summary>
public virtual bool Fresh { get; init; }
/// <summary>
/// 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.
/// </summary>
public virtual bool Connected { get; init; }
/// <summary>
/// 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 <see cref="RobustTestPair.IsTestPrototype(EntityPrototype)"/> if you need to
/// exclude test prototypes.
/// </summary>
public virtual bool NoLoadTestPrototypes { get; init; }
/// <summary>
/// Set this to true to disable the NetInterp CVar on the given server/client pair
/// </summary>
public virtual bool DisableInterpolate { get; init; }
/// <summary>
/// Set this to true to always clean up the server/client pair before giving it to another borrower
/// </summary>
public virtual bool Dirty { get; init; }
/// <summary>
/// Overrides the test name detection, and uses this in the test history instead
/// </summary>
public virtual string? TestName { get; set; }
/// <summary>
/// If set, this will be used to call <see cref="IRobustRandom.SetSeed"/>
/// </summary>
public virtual int? ServerSeed { get; set; }
/// <summary>
/// If set, this will be used to call <see cref="IRobustRandom.SetSeed"/>
/// </summary>
public virtual int? ClientSeed { get; set; }
#region Inferred Properties
/// <summary>
/// If the returned pair must not be reused
/// </summary>
public virtual bool MustNotBeReused => Destructive || NoLoadTestPrototypes;
/// <summary>
/// If the given pair must be brand new
/// </summary>
public virtual bool MustBeNew => Fresh || NoLoadTestPrototypes;
#endregion
/// <summary>
/// Tries to guess if we can skip recycling the server/client pair.
/// </summary>
/// <param name="nextSettings">The next set of settings the old pair will be set to</param>
/// <returns>If we can skip cleaning it up</returns>
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;
}
}
+332
View File
@@ -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<string> 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<TPair> : BasePoolManager where TPair : class, ITestPair, new()
{
private int _nextPairId;
private readonly Lock _pairLock = new();
private bool _initialized;
/// <summary>
/// Set of all pairs, and whether they are currently in-use
/// </summary>
protected readonly Dictionary<TPair, bool> Pairs = new();
private bool _dead;
private Exception? _poolFailureReason;
private Assembly[] _clientAssemblies = [];
private Assembly[] _serverAssemblies = [];
public override Assembly[] ClientAssemblies => _clientAssemblies;
public override Assembly[] ServerAssemblies => _serverAssemblies;
/// <summary>
/// Initialize the pool manager. Override this to configure what assemblies should get loaded.
/// </summary>
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();
}
/// <summary>
/// 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.
/// </summary>
public void Shutdown()
{
List<TPair> 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<TPair> 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;
}
}
/// <summary>
/// Used by TestPair after checking the server/client pair, Don't use this.
/// </summary>
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<TPair> 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<TestPrototypesAttribute>())
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);
}
}
}
}
@@ -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;
/// <summary>
/// Log handler intended for pooled integration tests.
/// </summary>
/// <remarks>
/// <para>
/// This class logs to two places: an NUnit <see cref="TestContext"/> (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.
/// </para>
/// <para>
/// The active test context can be swapped out so pooled instances can correctly have their logs attributed.
/// </para>
/// </remarks>
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;
}
}
+23
View File
@@ -0,0 +1,23 @@
using Robust.Shared.GameObjects;
using Robust.Shared.Map;
using Robust.Shared.Map.Components;
namespace Robust.UnitTesting.Pool;
/// <summary>
/// Simple data class that stored information about a map being used by a test.
/// </summary>
public sealed class TestMapData
{
public EntityUid MapUid { get; set; }
public Entity<MapGridComponent> 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; }
}
+285
View File
@@ -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<TServer, TClient>
{
/// <summary>
/// Convert a client-side uid into a server-side uid
/// </summary>
public EntityUid ToServerUid(EntityUid uid) => ConvertUid(uid, Client, Server);
/// <summary>
/// Convert a server-side uid into a client-side uid
/// </summary>
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<MetaDataComponent>(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;
}
/// <summary>
/// Execute a command on the server and wait some number of ticks.
/// </summary>
public async Task WaitCommand(string cmd, int numTicks = 10)
{
await Server.ExecuteCommand(cmd);
await RunTicksSync(numTicks);
}
/// <summary>
/// Execute a command on the client and wait some number of ticks.
/// </summary>
public async Task WaitClientCommand(string cmd, int numTicks = 10)
{
await Client.ExecuteCommand(cmd);
await RunTicksSync(numTicks);
}
/// <summary>
/// Retrieve all entity prototypes that have some component.
/// </summary>
public List<(EntityPrototype, T)> GetPrototypesWithComponent<T>(
HashSet<string>? ignored = null,
bool ignoreAbstract = true,
bool ignoreTestPrototypes = true)
where T : IComponent, new()
{
if (!Server.Resolve<IComponentFactory>().TryGetRegistration<T>(out var reg)
&& !Client.Resolve<IComponentFactory>().TryGetRegistration<T>(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<EntityPrototype>())
{
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;
}
/// <summary>
/// Retrieve all entity prototypes that have some component.
/// </summary>
public List<EntityPrototype> GetPrototypesWithComponent(
Type type,
HashSet<string>? ignored = null,
bool ignoreAbstract = true,
bool ignoreTestPrototypes = true)
{
if (!Server.Resolve<IComponentFactory>().TryGetRegistration(type, out var reg)
&& !Client.Resolve<IComponentFactory>().TryGetRegistration(type, out reg))
{
Assert.Fail($"Unknown component: {type.Name}");
return new();
}
var id = reg.Name;
var list = new List<EntityPrototype>();
foreach (var proto in Server.ProtoMan.EnumeratePrototypes<EntityPrototype>())
{
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<TPrototype>(string id) where TPrototype : IPrototype
{
return IsTestPrototype(typeof(TPrototype), id);
}
public bool IsTestPrototype<TPrototype>(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);
}
/// <summary>
/// Runs the server-client pair in sync
/// </summary>
/// <param name="ticks">How many ticks to run them for</param>
public async Task RunTicksSync(int ticks)
{
for (var i = 0; i < ticks; i++)
{
await Server.WaitRunTicks(1);
await Client.WaitRunTicks(1);
}
}
/// <summary>
/// Convert a time interval to some number of ticks.
/// </summary>
public int SecondsToTicks(float seconds)
{
return (int) Math.Ceiling(seconds / Server.Timing.TickPeriod.TotalSeconds);
}
/// <summary>
/// Run the server & client in sync for some amount of time
/// </summary>
public async Task RunSeconds(float seconds)
{
await RunTicksSync(SecondsToTicks(seconds));
}
/// <summary>
/// Runs the server-client pair in sync, but also ensures they are both idle each tick.
/// </summary>
/// <param name="runTicks">How many ticks to run</param>
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();
}
}
}
/// <summary>
/// Run the server/clients until the ticks are synchronized.
/// By default the client will be one tick ahead of the server.
/// </summary>
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));
}
/// <summary>
/// Creates a map with a single grid consisting of one tile.
/// </summary>
[MemberNotNull(nameof(TestMap))]
public async Task<TestMapData> CreateTestMap(bool initialized, ushort tileTypeId)
{
TestMap = new TestMapData();
await Server.WaitIdleAsync();
var sys = Server.System<SharedMapSystem>();
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;
}
/// <inheritdoc cref="CreateTestMap(bool, ushort)"/>
[MemberNotNull(nameof(TestMap))]
public async Task<TestMapData> CreateTestMap(bool initialized, string tileName)
{
var defMan = Server.Resolve<ITileDefinitionManager>();
if (!defMan.TryGetDefinition(tileName, out var def))
Assert.Fail($"Unknown tile: {tileName}");
return await CreateTestMap(initialized, def?.TileId ?? 1);
}
}
+208
View File
@@ -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<TServer, TClient>
{
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.");
}
/// <summary>
/// This method gets called before any test pair gets returned to the pool.
/// </summary>
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<IRuntimeLog>();
if (sRuntimeLog.ExceptionCount > 0)
throw new Exception($"{nameof(CleanReturnAsync)}: Server logged exceptions");
var cRuntimeLog = Client.Resolve<IRuntimeLog>();
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}.");
}
}
/// <summary>
/// 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 <see cref="PairSettings.CanFastRecycle"/>).
/// </summary>
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");
}
/// <summary>
/// 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 <see cref="PairSettings.CanFastRecycle"/>).
/// In general, this method should also call <see cref="ApplySettings"/>.
/// </summary>
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);
}
/// <summary>
/// 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
/// <see cref="PairSettings.CanFastRecycle"/> should have caused <see cref="Recycle"/> to be invoked, which should
/// already have applied those settings.
/// </summary>
public async Task ApplySettings(PairSettings next)
{
await ApplySettings(Client, next);
await ApplySettings(Server, next);
}
/// <inheritdoc cref="ApplySettings(PairSettings)"/>
[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));
}
/// <summary>
/// Invoked after a test pair has been recycled to validate that the settings have been properly applied.
/// </summary>
[MustCallBase]
public virtual void ValidateSettings(PairSettings settings)
{
var netMan = Client.Resolve<INetManager>();
Assert.That(netMan.IsConnected, Is.EqualTo(settings.Connected));
if (!settings.Connected)
return;
var baseClient = Client.Resolve<IBaseClient>();
var cPlayer = Client.Resolve<ISharedPlayerManager>();
var sPlayer = Server.Resolve<ISharedPlayerManager>();
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));
}
}
+244
View File
@@ -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;
/// <summary>
/// This object wraps a pooled server+client pair.
/// </summary>
public abstract partial class TestPair<TServer, TClient> : 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<string> 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<Type, HashSet<string>> _loadedPrototypes = new();
private HashSet<string> _loadedEntityPrototypes = new();
protected readonly Dictionary<string, object> ModifiedClientCvars = new();
protected readonly Dictionary<string, object> ModifiedServerCvars = new();
public async Task LoadPrototypes(List<string> 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<IRobustRandom>();
var sRand = Server.Resolve<IRobustRandom>();
_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<TClient> GenerateClient();
protected abstract Task<TServer> 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<IRobustRandom>();
if (Settings.ServerSeed is { } severSeed)
{
ServerSeed = severSeed;
sRand.SetSeed(ServerSeed);
}
else
{
ServerSeed = _nextServerSeed;
sRand.SetSeed(ServerSeed);
_nextServerSeed = sRand.Next();
}
var cRand = Client.Resolve<IRobustRandom>();
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<string> prototypes)
{
var changed = new Dictionary<Type, HashSet<string>>();
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();
}
/// <summary>
/// Reverts any cvars that were modified during a test back to their original values.
/// </summary>
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();
}
}
@@ -0,0 +1,13 @@
using System;
using JetBrains.Annotations;
namespace Robust.UnitTesting.Pool;
/// <summary>
/// Attribute that indicates that a string contains yaml prototype data that should be loaded by integration tests.
/// </summary>
[AttributeUsage(AttributeTargets.Field)]
[MeansImplicitUse]
public sealed class TestPrototypesAttribute : Attribute
{
}