diff --git a/Content.Benchmarks/DeviceNetworkingBenchmark.cs b/Content.Benchmarks/DeviceNetworkingBenchmark.cs index 8af7f2b262..8aeddd6304 100644 --- a/Content.Benchmarks/DeviceNetworkingBenchmark.cs +++ b/Content.Benchmarks/DeviceNetworkingBenchmark.cs @@ -2,6 +2,7 @@ using System.Threading.Tasks; using BenchmarkDotNet.Attributes; using Content.IntegrationTests; +using Content.IntegrationTests.Pair; using Content.IntegrationTests.Tests.DeviceNetwork; using Content.Server.DeviceNetwork; using Content.Server.DeviceNetwork.Systems; @@ -16,7 +17,7 @@ namespace Content.Benchmarks; [MemoryDiagnoser] public class DeviceNetworkingBenchmark { - private PairTracker _pair = default!; + private TestPair _pair = default!; private DeviceNetworkTestSystem _deviceNetTestSystem = default!; private DeviceNetworkSystem _deviceNetworkSystem = default!; private EntityUid _sourceEntity; diff --git a/Content.Benchmarks/MapLoadBenchmark.cs b/Content.Benchmarks/MapLoadBenchmark.cs index bd4213e438..15cbf96c36 100644 --- a/Content.Benchmarks/MapLoadBenchmark.cs +++ b/Content.Benchmarks/MapLoadBenchmark.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Threading.Tasks; using BenchmarkDotNet.Attributes; using Content.IntegrationTests; +using Content.IntegrationTests.Pair; using Content.Server.Maps; using Robust.Server.GameObjects; using Robust.Shared; @@ -17,7 +18,7 @@ namespace Content.Benchmarks; [Virtual] public class MapLoadBenchmark { - private PairTracker _pair = default!; + private TestPair _pair = default!; private MapLoaderSystem _mapLoader = default!; private IMapManager _mapManager = default!; diff --git a/Content.IntegrationTests/Pair/TestMapData.cs b/Content.IntegrationTests/Pair/TestMapData.cs new file mode 100644 index 0000000000..62fefd8722 --- /dev/null +++ b/Content.IntegrationTests/Pair/TestMapData.cs @@ -0,0 +1,19 @@ +using Robust.Shared.GameObjects; +using Robust.Shared.Map; +using Robust.Shared.Map.Components; + +namespace Content.IntegrationTests.Pair; + +/// +/// Simple data class that stored information about a map being used by a test. +/// +public sealed class TestMapData +{ + public EntityUid MapUid { get; set; } + public EntityUid GridUid { get; set; } + public MapId MapId { get; set; } + public MapGridComponent MapGrid { get; set; } = default!; + public EntityCoordinates GridCoords { get; set; } + public MapCoordinates MapCoords { get; set; } + public TileRef Tile { get; set; } +} \ No newline at end of file diff --git a/Content.IntegrationTests/Pair/TestPair.Helpers.cs b/Content.IntegrationTests/Pair/TestPair.Helpers.cs new file mode 100644 index 0000000000..fc48bfec30 --- /dev/null +++ b/Content.IntegrationTests/Pair/TestPair.Helpers.cs @@ -0,0 +1,39 @@ +#nullable enable +using System.Linq; +using Robust.Shared.Map; + +namespace Content.IntegrationTests.Pair; + +// Contains misc helper functions to make writing tests easier. +public sealed partial class TestPair +{ + /// + /// Creates a map, a grid, and a tile, and gives back references to them. + /// + public async Task CreateTestMap() + { + await Server.WaitIdleAsync(); + var tileDefinitionManager = Server.ResolveDependency(); + + var mapData = new TestMapData(); + await Server.WaitPost(() => + { + mapData.MapId = Server.MapMan.CreateMap(); + mapData.MapUid = Server.MapMan.GetMapEntityId(mapData.MapId); + mapData.MapGrid = Server.MapMan.CreateGrid(mapData.MapId); + mapData.GridUid = mapData.MapGrid.Owner; // Fixing this requires an engine PR. + mapData.GridCoords = new EntityCoordinates(mapData.GridUid, 0, 0); + var plating = tileDefinitionManager["Plating"]; + var platingTile = new Tile(plating.TileId); + mapData.MapGrid.SetTile(mapData.GridCoords, platingTile); + mapData.MapCoords = new MapCoordinates(0, 0, mapData.MapId); + mapData.Tile = mapData.MapGrid.GetAllTiles().First(); + }); + + if (Settings.Connected) + await RunTicksSync(10); + + TestMap = mapData; + return mapData; + } +} \ No newline at end of file diff --git a/Content.IntegrationTests/Pair/TestPair.Prototypes.cs b/Content.IntegrationTests/Pair/TestPair.Prototypes.cs new file mode 100644 index 0000000000..35893f6782 --- /dev/null +++ b/Content.IntegrationTests/Pair/TestPair.Prototypes.cs @@ -0,0 +1,64 @@ +#nullable enable +using System.Collections.Generic; +using Robust.Shared.Prototypes; +using Robust.Shared.Utility; +using Robust.UnitTesting; + +namespace Content.IntegrationTests.Pair; + +// This partial class contains helper methods to deal with yaml prototypes. +public sealed partial class TestPair +{ + private Dictionary> _loadedPrototypes = new(); + private HashSet _loadedEntityPrototypes = new(); + + public async Task LoadPrototypes(List prototypes) + { + await LoadPrototypes(Server, prototypes); + await LoadPrototypes(Client, prototypes); + } + + private async Task LoadPrototypes(RobustIntegrationTest.IntegrationInstance 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 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); + } +} \ No newline at end of file diff --git a/Content.IntegrationTests/Pair/TestPair.Recycle.cs b/Content.IntegrationTests/Pair/TestPair.Recycle.cs new file mode 100644 index 0000000000..bdd4fc7791 --- /dev/null +++ b/Content.IntegrationTests/Pair/TestPair.Recycle.cs @@ -0,0 +1,218 @@ +#nullable enable +using System.IO; +using System.Linq; +using Content.Server.GameTicking; +using Content.Server.Mind.Components; +using Content.Shared.CCVar; +using Content.Shared.GameTicking; +using Robust.Client; +using Robust.Server.Player; +using Robust.Shared.Exceptions; +using Robust.Shared.GameObjects; +using Robust.Shared.Network; + +namespace Content.IntegrationTests.Pair; + +// This partial class contains logic related to recycling & disposing test pairs. +public sealed partial class TestPair : IAsyncDisposable +{ + public PairState State { get; private set; } = PairState.Ready; + + 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."); + } + + private async Task OnCleanDispose() + { + if (TestMap != null) + { + await Server.WaitPost(() => Server.EntMan.DeleteEntity(TestMap.MapUid)); + TestMap = null; + } + + 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.ResolveDependency(); + if (sRuntimeLog.ExceptionCount > 0) + throw new Exception($"{nameof(CleanReturnAsync)}: Server logged exceptions"); + var cRuntimeLog = Client.ResolveDependency(); + 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; + PoolManager.NoCheckReturn(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(); + PoolManager.NoCheckReturn(this); + ClearContext(); + break; + default: + throw new Exception($"{nameof(DisposeAsync)}: Unexpected state. Pair: {Id}. State: {State}."); + } + } + + public async Task CleanPooledPair(PoolSettings settings, TextWriter testOut) + { + Settings = default!; + Watch.Restart(); + await testOut.WriteLineAsync($"Recycling..."); + + var gameTicker = Server.System(); + var cNetMgr = Client.ResolveDependency(); + + await RunTicksSync(1); + + // Disconnect the client if they are connected. + if (cNetMgr.IsConnected) + { + await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Disconnecting client."); + await Client.WaitPost(() => cNetMgr.ClientDisconnect("Test pooling cleanup disconnect")); + await RunTicksSync(1); + } + Assert.That(cNetMgr.IsConnected, Is.False); + + // Move to pre-round lobby. Required to toggle dummy ticker on and off + if (gameTicker.RunLevel != GameRunLevel.PreRoundLobby) + { + await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Restarting server."); + Assert.That(gameTicker.DummyTicker, Is.False); + Server.CfgMan.SetCVar(CCVars.GameLobbyEnabled, true); + await Server.WaitPost(() => gameTicker.RestartRound()); + await RunTicksSync(1); + } + + //Apply Cvars + await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Setting CVar "); + await PoolManager.SetupCVars(Client, settings); + await PoolManager.SetupCVars(Server, settings); + await RunTicksSync(1); + + // Restart server. + await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Restarting server again"); + await Server.WaitPost(() => gameTicker.RestartRound()); + await RunTicksSync(1); + + // Connect client + if (settings.ShouldBeConnected) + { + await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Connecting client"); + Client.SetConnectTarget(Server); + await Client.WaitPost(() => cNetMgr.ClientConnect(null!, 0, null!)); + } + + await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Idling"); + await ReallyBeIdle(); + await testOut.WriteLineAsync($"Recycling: {Watch.Elapsed.TotalMilliseconds} ms: Done recycling"); + } + + public void ValidateSettings(PoolSettings settings) + { + var cfg = Server.CfgMan; + Assert.That(cfg.GetCVar(CCVars.AdminLogsEnabled), Is.EqualTo(settings.AdminLogsEnabled)); + Assert.That(cfg.GetCVar(CCVars.GameLobbyEnabled), Is.EqualTo(settings.InLobby)); + Assert.That(cfg.GetCVar(CCVars.GameDummyTicker), Is.EqualTo(settings.UseDummyTicker)); + + var entMan = Server.ResolveDependency(); + var ticker = entMan.System(); + Assert.That(ticker.DummyTicker, Is.EqualTo(settings.UseDummyTicker)); + + var expectPreRound = settings.InLobby | settings.DummyTicker; + var expectedLevel = expectPreRound ? GameRunLevel.PreRoundLobby : GameRunLevel.InRound; + Assert.That(ticker.RunLevel, Is.EqualTo(expectedLevel)); + + var baseClient = Client.ResolveDependency(); + var netMan = Client.ResolveDependency(); + Assert.That(netMan.IsConnected, Is.Not.EqualTo(!settings.ShouldBeConnected)); + + if (!settings.ShouldBeConnected) + return; + + Assert.That(baseClient.RunLevel, Is.EqualTo(ClientRunLevel.InGame)); + var cPlayer = Client.ResolveDependency(); + var sPlayer = Server.ResolveDependency(); + Assert.That(sPlayer.Sessions.Count(), Is.EqualTo(1)); + var session = sPlayer.Sessions.Single(); + Assert.That(cPlayer.LocalPlayer?.Session.UserId, Is.EqualTo(session.UserId)); + + if (ticker.DummyTicker) + return; + + var status = ticker.PlayerGameStatuses[session.UserId]; + var expected = settings.InLobby + ? PlayerGameStatus.NotReadyToPlay + : PlayerGameStatus.JoinedGame; + + Assert.That(status, Is.EqualTo(expected)); + + if (settings.InLobby) + { + Assert.Null(session.AttachedEntity); + return; + } + + Assert.NotNull(session.AttachedEntity); + Assert.That(entMan.EntityExists(session.AttachedEntity)); + Assert.That(entMan.HasComponent(session.AttachedEntity)); + var mindCont = entMan.GetComponent(session.AttachedEntity!.Value); + Assert.NotNull(mindCont.Mind); + Assert.Null(mindCont.Mind?.VisitingEntity); + Assert.That(mindCont.Mind!.OwnedEntity, Is.EqualTo(session.AttachedEntity!.Value)); + Assert.That(mindCont.Mind.UserId, Is.EqualTo(session.UserId)); + } +} \ No newline at end of file diff --git a/Content.IntegrationTests/Pair/TestPair.Timing.cs b/Content.IntegrationTests/Pair/TestPair.Timing.cs new file mode 100644 index 0000000000..3487ea6801 --- /dev/null +++ b/Content.IntegrationTests/Pair/TestPair.Timing.cs @@ -0,0 +1,62 @@ +#nullable enable +using Robust.Shared.Timing; + +namespace Content.IntegrationTests.Pair; + +// This partial class contains methods for running the server/client pairs for some number of ticks +public sealed partial class TestPair +{ + /// + /// 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); + } + } + + /// + /// 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)); + } +} \ No newline at end of file diff --git a/Content.IntegrationTests/Pair/TestPair.cs b/Content.IntegrationTests/Pair/TestPair.cs new file mode 100644 index 0000000000..41af4b2c80 --- /dev/null +++ b/Content.IntegrationTests/Pair/TestPair.cs @@ -0,0 +1,111 @@ +#nullable enable +using System.Collections.Generic; +using System.IO; +using Content.Server.GameTicking; +using Robust.Shared.GameObjects; +using Robust.Shared.IoC; +using Robust.Shared.Network; +using Robust.Shared.Timing; +using Robust.UnitTesting; + +namespace Content.IntegrationTests.Pair; + +/// +/// This object wraps a pooled server+client pair. +/// +public sealed partial class TestPair +{ + // TODO remove this. + [Obsolete("Field access is redundant")] + public TestPair Pair => this; + + public readonly int Id; + private bool _initialized; + private TextWriter _testOut = default!; + public readonly Stopwatch Watch = new(); + public readonly List TestHistory = new(); + public PoolSettings Settings = default!; + public TestMapData? TestMap; + public RobustIntegrationTest.ServerIntegrationInstance Server { get; private set; } = default!; + public RobustIntegrationTest.ClientIntegrationInstance Client { get; private set; } = default!; + + public PoolTestLogHandler ServerLogHandler { get; private set; } = default!; + public PoolTestLogHandler ClientLogHandler { get; private set; } = default!; + + public TestPair(int id) + { + Id = id; + } + + public async Task Initialize(PoolSettings settings, TextWriter testOut, List testPrototypes) + { + if (_initialized) + throw new InvalidOperationException("Already initialized"); + + _initialized = true; + Settings = settings; + (Client, ClientLogHandler) = await PoolManager.GenerateClient(settings, testOut); + (Server, ServerLogHandler) = await PoolManager.GenerateServer(settings, testOut); + ActivateContext(testOut); + + if (!settings.NoLoadTestPrototypes) + await LoadPrototypes(testPrototypes!); + + if (!settings.UseDummyTicker) + { + var gameTicker = Server.ResolveDependency().System(); + await Server.WaitPost(() => gameTicker.RestartRound()); + } + + if (settings.ShouldBeConnected) + { + Client.SetConnectTarget(Server); + await Client.WaitPost(() => + { + var netMgr = IoCManager.Resolve(); + if (!netMgr.IsConnected) + { + netMgr.ClientConnect(null!, 0, null!); + } + }); + await ReallyBeIdle(10); + await Client.WaitRunTicks(1); + } + } + + public void Kill() + { + State = PairState.Dead; + 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 enum PairState : byte + { + Ready = 0, + InUse = 1, + CleanDisposed = 2, + Dead = 3, + } +} \ No newline at end of file diff --git a/Content.IntegrationTests/PoolManager.Cvars.cs b/Content.IntegrationTests/PoolManager.Cvars.cs new file mode 100644 index 0000000000..dfdbddd923 --- /dev/null +++ b/Content.IntegrationTests/PoolManager.Cvars.cs @@ -0,0 +1,70 @@ +#nullable enable +using Content.Shared.CCVar; +using Robust.Shared; +using Robust.Shared.Configuration; +using Robust.UnitTesting; + +namespace Content.IntegrationTests; + +// Partial class containing cvar logic +public static partial class PoolManager +{ + private static readonly (string cvar, string value)[] TestCvars = + { + // @formatter:off + (CCVars.DatabaseSynchronous.Name, "true"), + (CCVars.DatabaseSqliteDelay.Name, "0"), + (CCVars.HolidaysEnabled.Name, "false"), + (CCVars.GameMap.Name, TestMap), + (CCVars.AdminLogsQueueSendDelay.Name, "0"), + (CVars.NetPVS.Name, "false"), + (CCVars.NPCMaxUpdates.Name, "999999"), + (CVars.ThreadParallelCount.Name, "1"), + (CCVars.GameRoleTimers.Name, "false"), + (CCVars.GridFill.Name, "false"), + (CCVars.ArrivalsShuttles.Name, "false"), + (CCVars.EmergencyShuttleEnabled.Name, "false"), + (CCVars.ProcgenPreload.Name, "false"), + (CCVars.WorldgenEnabled.Name, "false"), + (CVars.ReplayClientRecordingEnabled.Name, "false"), + (CVars.ReplayServerRecordingEnabled.Name, "false"), + (CCVars.GameDummyTicker.Name, "true"), + (CCVars.GameLobbyEnabled.Name, "false"), + (CCVars.ConfigPresetDevelopment.Name, "false"), + (CCVars.AdminLogsEnabled.Name, "false"), + (CVars.NetBufferSize.Name, "0") + }; + + public static async Task SetupCVars(RobustIntegrationTest.IntegrationInstance instance, PoolSettings settings) + { + var cfg = instance.ResolveDependency(); + await instance.WaitPost(() => + { + if (cfg.IsCVarRegistered(CCVars.GameDummyTicker.Name)) + cfg.SetCVar(CCVars.GameDummyTicker, settings.UseDummyTicker); + + if (cfg.IsCVarRegistered(CCVars.GameLobbyEnabled.Name)) + cfg.SetCVar(CCVars.GameLobbyEnabled, settings.InLobby); + + if (cfg.IsCVarRegistered(CVars.NetInterp.Name)) + cfg.SetCVar(CVars.NetInterp, settings.DisableInterpolate); + + if (cfg.IsCVarRegistered(CCVars.GameMap.Name)) + cfg.SetCVar(CCVars.GameMap, settings.Map); + + if (cfg.IsCVarRegistered(CCVars.AdminLogsEnabled.Name)) + cfg.SetCVar(CCVars.AdminLogsEnabled, settings.AdminLogsEnabled); + + if (cfg.IsCVarRegistered(CVars.NetInterp.Name)) + cfg.SetCVar(CVars.NetInterp, !settings.DisableInterpolate); + }); + } + + private static void SetDefaultCVars(RobustIntegrationTest.IntegrationOptions options) + { + foreach (var (cvar, value) in TestCvars) + { + options.CVarOverrides[cvar] = value; + } + } +} \ No newline at end of file diff --git a/Content.IntegrationTests/PoolManager.cs b/Content.IntegrationTests/PoolManager.cs index 64f94e136b..af89bce99d 100644 --- a/Content.IntegrationTests/PoolManager.cs +++ b/Content.IntegrationTests/PoolManager.cs @@ -7,6 +7,7 @@ using System.Text; using System.Threading; using Content.Client.IoC; using Content.Client.Parallax.Managers; +using Content.IntegrationTests.Pair; using Content.IntegrationTests.Tests; using Content.IntegrationTests.Tests.Destructible; using Content.IntegrationTests.Tests.DeviceNetwork; @@ -16,21 +17,19 @@ using Content.Server.Mind.Components; using Content.Shared.CCVar; using Content.Shared.GameTicking; using Robust.Client; +using Robust.Client.State; using Robust.Server; using Robust.Server.Player; using Robust.Shared; using Robust.Shared.Configuration; using Robust.Shared.ContentPack; -using Robust.Shared.Exceptions; using Robust.Shared.GameObjects; using Robust.Shared.IoC; using Robust.Shared.Log; using Robust.Shared.Map; -using Robust.Shared.Map.Components; using Robust.Shared.Network; using Robust.Shared.Prototypes; using Robust.Shared.Timing; -using Robust.Shared.Utility; using Robust.UnitTesting; [assembly: LevelOfParallelism(3)] @@ -43,48 +42,16 @@ namespace Content.IntegrationTests; public static partial class PoolManager { public const string TestMap = "Empty"; - - private static readonly (string cvar, string value)[] TestCvars = - { - // @formatter:off - (CCVars.DatabaseSynchronous.Name, "true"), - (CCVars.DatabaseSqliteDelay.Name, "0"), - (CCVars.HolidaysEnabled.Name, "false"), - (CCVars.GameMap.Name, TestMap), - (CCVars.AdminLogsQueueSendDelay.Name, "0"), - (CVars.NetPVS.Name, "false"), - (CCVars.NPCMaxUpdates.Name, "999999"), - (CVars.ThreadParallelCount.Name, "1"), - (CCVars.GameRoleTimers.Name, "false"), - (CCVars.GridFill.Name, "false"), - (CCVars.ArrivalsShuttles.Name, "false"), - (CCVars.EmergencyShuttleEnabled.Name, "false"), - (CCVars.ProcgenPreload.Name, "false"), - (CCVars.WorldgenEnabled.Name, "false"), - (CVars.ReplayClientRecordingEnabled.Name, "false"), - (CVars.ReplayServerRecordingEnabled.Name, "false"), - (CCVars.GameDummyTicker.Name, "true"), - (CCVars.GameLobbyEnabled.Name, "false"), - (CCVars.ConfigPresetDevelopment.Name, "false"), - (CCVars.AdminLogsEnabled.Name, "false"), - - // This breaks some tests. - // TODO: Figure out which tests this breaks. - (CVars.NetBufferSize.Name, "0") - - // @formatter:on - }; - private static int _pairId; private static readonly object PairLock = new(); private static bool _initialized; // Pair, IsBorrowed - private static readonly Dictionary Pairs = new(); + private static readonly Dictionary Pairs = new(); private static bool _dead; private static Exception? _poolFailureReason; - private static async Task<(RobustIntegrationTest.ServerIntegrationInstance, PoolTestLogHandler)> GenerateServer( + public static async Task<(RobustIntegrationTest.ServerIntegrationInstance, PoolTestLogHandler)> GenerateServer( PoolSettings poolSettings, TextWriter testOut) { @@ -134,7 +101,7 @@ public static partial class PoolManager /// public static void Shutdown() { - List localPairs; + List localPairs; lock (PairLock) { if (_dead) @@ -156,11 +123,11 @@ public static partial class PoolManager lock (PairLock) { var builder = new StringBuilder(); - var pairs = Pairs.Keys.OrderBy(pair => pair.PairId); + var pairs = Pairs.Keys.OrderBy(pair => pair.Id); foreach (var pair in pairs) { var borrowed = Pairs[pair]; - builder.AppendLine($"Pair {pair.PairId}, Tests Run: {pair.TestHistory.Count}, Borrowed: {borrowed}"); + 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]}"); @@ -171,7 +138,7 @@ public static partial class PoolManager } } - private static async Task<(RobustIntegrationTest.ClientIntegrationInstance, PoolTestLogHandler)> GenerateClient( + public static async Task<(RobustIntegrationTest.ClientIntegrationInstance, PoolTestLogHandler)> GenerateClient( PoolSettings poolSettings, TextWriter testOut) { @@ -225,45 +192,12 @@ public static partial class PoolManager return (client, logHandler); } - private static async Task SetupCVars(RobustIntegrationTest.IntegrationInstance instance, PoolSettings settings) - { - var cfg = instance.ResolveDependency(); - await instance.WaitPost(() => - { - if (cfg.IsCVarRegistered(CCVars.GameDummyTicker.Name)) - cfg.SetCVar(CCVars.GameDummyTicker, settings.UseDummyTicker); - - if (cfg.IsCVarRegistered(CCVars.GameLobbyEnabled.Name)) - cfg.SetCVar(CCVars.GameLobbyEnabled, settings.InLobby); - - if (cfg.IsCVarRegistered(CVars.NetInterp.Name)) - cfg.SetCVar(CVars.NetInterp, settings.DisableInterpolate); - - if (cfg.IsCVarRegistered(CCVars.GameMap.Name)) - cfg.SetCVar(CCVars.GameMap, settings.Map); - - if (cfg.IsCVarRegistered(CCVars.AdminLogsEnabled.Name)) - cfg.SetCVar(CCVars.AdminLogsEnabled, settings.AdminLogsEnabled); - - if (cfg.IsCVarRegistered(CVars.NetInterp.Name)) - cfg.SetCVar(CVars.NetInterp, !settings.DisableInterpolate); - }); - } - - private static void SetDefaultCVars(RobustIntegrationTest.IntegrationOptions options) - { - foreach (var (cvar, value) in TestCvars) - { - options.CVarOverrides[cvar] = value; - } - } - /// - /// Gets a , which can be used to get access to a server, and client + /// Gets a , which can be used to get access to a server, and client /// /// See /// - public static async Task GetServerClient(PoolSettings? poolSettings = null) + public static async Task GetServerClient(PoolSettings? poolSettings = null) { return await GetServerClientPair(poolSettings ?? new PoolSettings()); } @@ -273,7 +207,7 @@ public static partial class PoolManager return testContext.Test.FullName.Replace("Content.IntegrationTests.Tests.", ""); } - private static async Task GetServerClientPair(PoolSettings poolSettings) + private static async Task GetServerClientPair(PoolSettings poolSettings) { if (!_initialized) throw new InvalidOperationException($"Pool manager has not been initialized"); @@ -286,7 +220,7 @@ public static partial class PoolManager var currentTestName = poolSettings.TestName ?? GetDefaultTestName(testContext); var poolRetrieveTimeWatch = new Stopwatch(); await testOut.WriteLineAsync($"{nameof(GetServerClientPair)}: Called by test {currentTestName}"); - Pair? pair = null; + TestPair? pair = null; try { poolRetrieveTimeWatch.Start(); @@ -295,11 +229,6 @@ public static partial class PoolManager await testOut.WriteLineAsync( $"{nameof(GetServerClientPair)}: Creating pair, because settings of pool settings"); pair = await CreateServerClientPair(poolSettings, testOut); - - // Newly created pairs should always be in a valid state. - await RunTicksSync(pair, 5); - await SyncTicks(pair, targetDelta: 1); - ValidatePair(pair, poolSettings); } else { @@ -308,7 +237,6 @@ public static partial class PoolManager if (pair != null) { pair.ActivateContext(testOut); - await testOut.WriteLineAsync($"{nameof(GetServerClientPair)}: Suitable pair found"); var canSkip = pair.Settings.CanFastRecycle(poolSettings); @@ -317,17 +245,16 @@ public static partial class PoolManager await testOut.WriteLineAsync($"{nameof(GetServerClientPair)}: Cleanup not needed, Skipping cleanup of pair"); await SetupCVars(pair.Client, poolSettings); await SetupCVars(pair.Server, poolSettings); - await RunTicksSync(pair, 1); + await pair.RunTicksSync(1); } else { await testOut.WriteLineAsync($"{nameof(GetServerClientPair)}: Cleaning existing pair"); - await CleanPooledPair(poolSettings, pair, testOut); + await pair.CleanPooledPair(poolSettings, testOut); } - await RunTicksSync(pair, 5); - await SyncTicks(pair, targetDelta: 1); - ValidatePair(pair, poolSettings); + await pair.RunTicksSync(5); + await pair.SyncTicks(targetDelta: 1); } else { @@ -335,114 +262,65 @@ public static partial class PoolManager pair = await CreateServerClientPair(poolSettings, testOut); } } - } finally { if (pair != null && pair.TestHistory.Count > 1) { - await testOut.WriteLineAsync($"{nameof(GetServerClientPair)}: Pair {pair.PairId} Test History Start"); + await testOut.WriteLineAsync($"{nameof(GetServerClientPair)}: Pair {pair.Id} Test History Start"); for (var i = 0; i < pair.TestHistory.Count; i++) { - await testOut.WriteLineAsync($"- Pair {pair.PairId} Test #{i}: {pair.TestHistory[i]}"); + await testOut.WriteLineAsync($"- Pair {pair.Id} Test #{i}: {pair.TestHistory[i]}"); } - await testOut.WriteLineAsync($"{nameof(GetServerClientPair)}: Pair {pair.PairId} Test History End"); + await testOut.WriteLineAsync($"{nameof(GetServerClientPair)}: Pair {pair.Id} Test History End"); } } + + pair.ValidateSettings(poolSettings); + var poolRetrieveTime = poolRetrieveTimeWatch.Elapsed; await testOut.WriteLineAsync( - $"{nameof(GetServerClientPair)}: Retrieving pair {pair.PairId} from pool took {poolRetrieveTime.TotalMilliseconds} ms"); + $"{nameof(GetServerClientPair)}: Retrieving pair {pair.Id} from pool took {poolRetrieveTime.TotalMilliseconds} ms"); await testOut.WriteLineAsync( - $"{nameof(GetServerClientPair)}: Returning pair {pair.PairId}"); + $"{nameof(GetServerClientPair)}: Returning pair {pair.Id}"); pair.Settings = poolSettings; pair.TestHistory.Add(currentTestName); - var usageWatch = new Stopwatch(); - usageWatch.Start(); - - return new PairTracker(testOut) - { - Pair = pair, - UsageWatch = usageWatch - }; + pair.Watch.Restart(); + return pair; } - private static void ValidatePair(Pair pair, PoolSettings settings) - { - var cfg = pair.Server.ResolveDependency(); - Assert.That(cfg.GetCVar(CCVars.AdminLogsEnabled), Is.EqualTo(settings.AdminLogsEnabled)); - Assert.That(cfg.GetCVar(CCVars.GameLobbyEnabled), Is.EqualTo(settings.InLobby)); - Assert.That(cfg.GetCVar(CCVars.GameDummyTicker), Is.EqualTo(settings.UseDummyTicker)); - - var entMan = pair.Server.ResolveDependency(); - var ticker = entMan.System(); - Assert.That(ticker.DummyTicker, Is.EqualTo(settings.UseDummyTicker)); - - var expectPreRound = settings.InLobby | settings.DummyTicker; - var expectedLevel = expectPreRound ? GameRunLevel.PreRoundLobby : GameRunLevel.InRound; - Assert.That(ticker.RunLevel, Is.EqualTo(expectedLevel)); - - var baseClient = pair.Client.ResolveDependency(); - var netMan = pair.Client.ResolveDependency(); - Assert.That(netMan.IsConnected, Is.Not.EqualTo(!settings.ShouldBeConnected)); - - if (!settings.ShouldBeConnected) - return; - - Assert.That(baseClient.RunLevel, Is.EqualTo(ClientRunLevel.InGame)); - var cPlayer = pair.Client.ResolveDependency(); - var sPlayer = pair.Server.ResolveDependency(); - Assert.That(sPlayer.Sessions.Count(), Is.EqualTo(1)); - var session = sPlayer.Sessions.Single(); - Assert.That(cPlayer.LocalPlayer?.Session.UserId, Is.EqualTo(session.UserId)); - - if (ticker.DummyTicker) - return; - - var status = ticker.PlayerGameStatuses[session.UserId]; - var expected = settings.InLobby - ? PlayerGameStatus.NotReadyToPlay - : PlayerGameStatus.JoinedGame; - - Assert.That(status, Is.EqualTo(expected)); - - if (settings.InLobby) - { - Assert.Null(session.AttachedEntity); - return; - } - - Assert.NotNull(session.AttachedEntity); - Assert.That(entMan.EntityExists(session.AttachedEntity)); - Assert.That(entMan.HasComponent(session.AttachedEntity)); - var mindCont = entMan.GetComponent(session.AttachedEntity!.Value); - Assert.NotNull(mindCont.Mind); - Assert.Null(mindCont.Mind?.VisitingEntity); - Assert.That(mindCont.Mind!.OwnedEntity, Is.EqualTo(session.AttachedEntity!.Value)); - Assert.That(mindCont.Mind.UserId, Is.EqualTo(session.UserId)); - } - - private static Pair? GrabOptimalPair(PoolSettings poolSettings) + private static TestPair? GrabOptimalPair(PoolSettings poolSettings) { lock (PairLock) { - Pair? fallback = null; + TestPair? 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) { + fallback.Use(); Pairs[fallback!] = true; } + + if (fallback == null && _pairId > 8) + { + var x = 2; + } + return fallback; } } @@ -451,78 +329,19 @@ public static partial class PoolManager /// Used by PairTracker after checking the server/client pair, Don't use this. /// /// - public static void NoCheckReturn(Pair pair) + public static void NoCheckReturn(TestPair pair) { lock (PairLock) { - if (pair.Dead) - { + if (pair.State == TestPair.PairState.Dead) Pairs.Remove(pair); - } - else - { + else if (pair.State == TestPair.PairState.Ready) Pairs[pair] = false; - } + else + throw new InvalidOperationException($"Attempted to return a pair in an invalid state. Pair: {pair.Id}. State: {pair.State}."); } } - private static async Task CleanPooledPair(PoolSettings settings, Pair pair, TextWriter testOut) - { - pair.Settings = default!; - var methodWatch = new Stopwatch(); - methodWatch.Start(); - await testOut.WriteLineAsync($"Recycling..."); - - var configManager = pair.Server.ResolveDependency(); - var entityManager = pair.Server.ResolveDependency(); - var gameTicker = entityManager.System(); - var cNetMgr = pair.Client.ResolveDependency(); - - await RunTicksSync(pair, 1); - - // Disconnect the client if they are connected. - if (cNetMgr.IsConnected) - { - await testOut.WriteLineAsync($"Recycling: {methodWatch.Elapsed.TotalMilliseconds} ms: Disconnecting client."); - await pair.Client.WaitPost(() => cNetMgr.ClientDisconnect("Test pooling cleanup disconnect")); - await RunTicksSync(pair, 1); - } - Assert.That(cNetMgr.IsConnected, Is.False); - - // Move to pre-round lobby. Required to toggle dummy ticker on and off - if (gameTicker.RunLevel != GameRunLevel.PreRoundLobby) - { - await testOut.WriteLineAsync($"Recycling: {methodWatch.Elapsed.TotalMilliseconds} ms: Restarting server."); - Assert.That(gameTicker.DummyTicker, Is.False); - configManager.SetCVar(CCVars.GameLobbyEnabled, true); - await pair.Server.WaitPost(() => gameTicker.RestartRound()); - await RunTicksSync(pair, 1); - } - - //Apply Cvars - await testOut.WriteLineAsync($"Recycling: {methodWatch.Elapsed.TotalMilliseconds} ms: Setting CVar "); - await SetupCVars(pair.Client, settings); - await SetupCVars(pair.Server, settings); - await RunTicksSync(pair, 1); - - // Restart server. - await testOut.WriteLineAsync($"Recycling: {methodWatch.Elapsed.TotalMilliseconds} ms: Restarting server again"); - await pair.Server.WaitPost(() => gameTicker.RestartRound()); - await RunTicksSync(pair, 1); - - // Connect client - if (settings.ShouldBeConnected) - { - await testOut.WriteLineAsync($"Recycling: {methodWatch.Elapsed.TotalMilliseconds} ms: Connecting client"); - pair.Client.SetConnectTarget(pair.Server); - await pair.Client.WaitPost(() => cNetMgr.ClientConnect(null!, 0, null!)); - } - - await testOut.WriteLineAsync($"Recycling: {methodWatch.Elapsed.TotalMilliseconds} ms: Idling"); - await ReallyBeIdle(pair); - await testOut.WriteLineAsync($"Recycling: {methodWatch.Elapsed.TotalMilliseconds} ms: Done recycling"); - } - private static void DieIfPoolFailure() { if (_poolFailureReason != null) @@ -543,52 +362,23 @@ we are just going to end this here to save a lot of time. This is the exception } } - private static async Task CreateServerClientPair(PoolSettings poolSettings, TextWriter testOut) + private static async Task CreateServerClientPair(PoolSettings poolSettings, TextWriter testOut) { - Pair pair; try { - var (client, clientLog) = await GenerateClient(poolSettings, testOut); - var (server, serverLog) = await GenerateServer(poolSettings, testOut); - pair = new Pair - { - Server = server, - ServerLogHandler = serverLog, - Client = client, - ClientLogHandler = clientLog, - PairId = Interlocked.Increment(ref _pairId) - }; - - if (!poolSettings.NoLoadTestPrototypes) - await pair.LoadPrototypes(_testPrototypes!); + var id = Interlocked.Increment(ref _pairId); + var pair = new TestPair(id); + await pair.Initialize(poolSettings, testOut, _testPrototypes); + pair.Use(); + await pair.RunTicksSync(5); + await pair.SyncTicks(targetDelta: 1); + return pair; } catch (Exception ex) { _poolFailureReason = ex; throw; } - - if (!poolSettings.UseDummyTicker) - { - var gameTicker = pair.Server.ResolveDependency().System(); - await pair.Server.WaitPost(() => gameTicker.RestartRound()); - } - - if (poolSettings.ShouldBeConnected) - { - pair.Client.SetConnectTarget(pair.Server); - await pair.Client.WaitPost(() => - { - var netMgr = IoCManager.Resolve(); - if (!netMgr.IsConnected) - { - netMgr.ClientConnect(null!, 0, null!); - } - }); - await ReallyBeIdle(pair, 10); - await pair.Client.WaitRunTicks(1); - } - return pair; } /// @@ -596,36 +386,10 @@ we are just going to end this here to save a lot of time. This is the exception /// /// A pairTracker /// A TestMapData - public static async Task CreateTestMap(PairTracker pairTracker) + [Obsolete("use TestPair.CreateMap")] + public static async Task CreateTestMap(TestPair pairTracker) { - var server = pairTracker.Pair.Server; - - await server.WaitIdleAsync(); - - var settings = pairTracker.Pair.Settings; - var mapManager = server.ResolveDependency(); - var tileDefinitionManager = server.ResolveDependency(); - - var mapData = new TestMapData(); - await server.WaitPost(() => - { - mapData.MapId = mapManager.CreateMap(); - mapData.MapUid = mapManager.GetMapEntityId(mapData.MapId); - mapData.MapGrid = mapManager.CreateGrid(mapData.MapId); - mapData.GridUid = mapData.MapGrid.Owner; // Fixing this requires an engine PR. - mapData.GridCoords = new EntityCoordinates(mapData.GridUid, 0, 0); - var plating = tileDefinitionManager["Plating"]; - var platingTile = new Tile(plating.TileId); - mapData.MapGrid.SetTile(mapData.GridCoords, platingTile); - mapData.MapCoords = new MapCoordinates(0, 0, mapData.MapId); - mapData.Tile = mapData.MapGrid.GetAllTiles().First(); - }); - if (settings.ShouldBeConnected) - { - await RunTicksSync(pairTracker.Pair, 10); - } - - return mapData; + return await pairTracker.CreateTestMap(); } /// @@ -633,7 +397,8 @@ we are just going to end this here to save a lot of time. This is the exception /// /// A server/client pair /// How many ticks to run them for - public static async Task RunTicksSync(Pair pair, int ticks) + [Obsolete("use TestPair.RunTicks")] + public static async Task RunTicksSync(TestPair pair, int ticks) { for (var i = 0; i < ticks; i++) { @@ -641,51 +406,7 @@ we are just going to end this here to save a lot of time. This is the exception await pair.Client.WaitRunTicks(1); } } - - /// - /// Runs the server/client in sync, but also ensures they are both idle each tick. - /// - /// The server/client pair - /// How many ticks to run - public static async Task ReallyBeIdle(Pair pair, int runTicks = 25) - { - for (var i = 0; i < runTicks; i++) - { - await pair.Client.WaitRunTicks(1); - await pair.Server.WaitRunTicks(1); - for (var idleCycles = 0; idleCycles < 4; idleCycles++) - { - await pair.Client.WaitIdleAsync(); - await pair.Server.WaitIdleAsync(); - } - } - } - - /// - /// Run the server/clients until the ticks are synchronized. - /// By default the client will be one tick ahead of the server. - /// - public static async Task SyncTicks(Pair pair, int targetDelta = 1) - { - var sTiming = pair.Server.ResolveDependency(); - var cTiming = pair.Client.ResolveDependency(); - var sTick = (int)sTiming.CurTick.Value; - var cTick = (int)cTiming.CurTick.Value; - var delta = cTick - sTick; - - if (delta == targetDelta) - return; - if (delta > targetDelta) - await pair.Server.WaitRunTicks(delta - targetDelta); - else - await pair.Client.WaitRunTicks(targetDelta - delta); - - sTick = (int)sTiming.CurTick.Value; - cTick = (int)cTiming.CurTick.Value; - delta = cTick - sTick; - Assert.That(delta, Is.EqualTo(targetDelta)); - } - + /// /// Runs a server, or a client until a condition is true /// @@ -743,7 +464,7 @@ we are just going to end this here to save a lot of time. This is the exception /// /// Helper method that retrieves all entity prototypes that have some component. /// - public static List GetEntityPrototypes(RobustIntegrationTest.IntegrationInstance instance) where T : Component + public static List GetPrototypesWithComponent(RobustIntegrationTest.IntegrationInstance instance) where T : Component { var protoMan = instance.ResolveDependency(); var compFact = instance.ResolveDependency(); @@ -771,339 +492,4 @@ we are just going to end this here to save a lot of time. This is the exception _initialized = true; DiscoverTestPrototypes(assembly); } -} - -/// -/// Settings for the pooled server, and client pair. -/// Some options are for changing the pair, and others are -/// so the pool can properly clean up what you borrowed. -/// -public sealed class PoolSettings -{ - /// - /// If the returned pair must not be reused - /// - public bool MustNotBeReused => Destructive || NoLoadContent || NoLoadTestPrototypes; - - /// - /// If the given pair must be brand new - /// - public bool MustBeNew => Fresh || NoLoadContent || NoLoadTestPrototypes; - - /// - /// Set to true if the test will ruin the server/client pair. - /// - public bool Destructive { get; init; } - - /// - /// Set to true if the given server/client pair should be created fresh. - /// - public bool Fresh { get; init; } - - /// - /// Set to true if the given server should be using a dummy ticker. Ignored if is true. - /// - public bool DummyTicker { get; init; } = true; - - public bool UseDummyTicker => !InLobby && DummyTicker; - - /// - /// If true, this enables the creation of admin logs during the test. - /// - public bool AdminLogsEnabled { 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. - /// If is true, this option is ignored. - /// - public bool Connected { get; init; } - - public bool ShouldBeConnected => InLobby || Connected; - - /// - /// Set to true if the given server/client pair should be in the lobby. - /// If the pair is not in the lobby at the end of the test, this test must be marked as dirty. - /// - /// - /// If this is enabled, the value of is ignored. - /// - public bool InLobby { get; init; } - - /// - /// Set this to true to skip loading the content files. - /// Note: This setting won't work with a client. - /// - public bool NoLoadContent { 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 prototypees. - /// - public bool NoLoadTestPrototypes { get; init; } - - /// - /// Set this to true to disable the NetInterp CVar on the given server/client pair - /// - public bool DisableInterpolate { get; init; } - - /// - /// Set this to true to always clean up the server/client pair before giving it to another borrower - /// - public bool Dirty { get; init; } - - /// - /// Set this to the path of a map to have the given server/client pair load the map. - /// - public string Map { get; init; } = PoolManager.TestMap; - - /// - /// Overrides the test name detection, and uses this in the test history instead - /// - public string? TestName { get; set; } - - /// - /// 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 bool CanFastRecycle(PoolSettings 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; - - // Check that certain settings match. - return !ShouldBeConnected == !nextSettings.ShouldBeConnected - && UseDummyTicker == nextSettings.UseDummyTicker - && Map == nextSettings.Map - && InLobby == nextSettings.InLobby; - } -} - -/// -/// Holds a reference to things commonly needed when testing on a map -/// -public sealed class TestMapData -{ - public EntityUid MapUid { get; set; } - public EntityUid GridUid { get; set; } - public MapId MapId { get; set; } - public MapGridComponent MapGrid { get; set; } = default!; - public EntityCoordinates GridCoords { get; set; } - public MapCoordinates MapCoords { get; set; } - public TileRef Tile { get; set; } -} - -/// -/// A server/client pair -/// -public sealed class Pair -{ - public bool Dead { get; private set; } - public int PairId { get; init; } - public List TestHistory { get; set; } = new(); - public PoolSettings Settings { get; set; } = default!; - public RobustIntegrationTest.ServerIntegrationInstance Server { get; init; } = default!; - public RobustIntegrationTest.ClientIntegrationInstance Client { get; init; } = default!; - - public PoolTestLogHandler ServerLogHandler { get; init; } = default!; - public PoolTestLogHandler ClientLogHandler { get; init; } = default!; - - private Dictionary> _loadedPrototypes = new(); - private HashSet _loadedEntityPrototypes = new(); - - public void Kill() - { - Dead = true; - Server.Dispose(); - Client.Dispose(); - } - - public void ClearContext() - { - ServerLogHandler.ClearContext(); - ClientLogHandler.ClearContext(); - } - - public void ActivateContext(TextWriter testOut) - { - ServerLogHandler.ActivateContext(testOut); - ClientLogHandler.ActivateContext(testOut); - } - - public async Task LoadPrototypes(List prototypes) - { - await LoadPrototypes(Server, prototypes); - await LoadPrototypes(Client, prototypes); - } - - private async Task LoadPrototypes(RobustIntegrationTest.IntegrationInstance instance, List prototypes) - { - var changed = new Dictionary>(); - var protoMan = instance.ResolveDependency(); - foreach (var file in prototypes) - { - protoMan.LoadString(file, changed: changed); - } - - await instance.WaitPost(() => 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 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); - } -} - -/// -/// Used by the pool to keep track of a borrowed server/client pair. -/// -public sealed class PairTracker : IAsyncDisposable -{ - private readonly TextWriter _testOut; - private int _disposed; - public Stopwatch UsageWatch { get; set; } = default!; - public Pair Pair { get; init; } = default!; - - public PairTracker(TextWriter testOut) - { - _testOut = testOut; - } - - // Convenience properties. - public RobustIntegrationTest.ServerIntegrationInstance Server => Pair.Server; - public RobustIntegrationTest.ClientIntegrationInstance Client => Pair.Client; - - private async Task OnDirtyDispose() - { - var usageTime = UsageWatch.Elapsed; - await _testOut.WriteLineAsync($"{nameof(DisposeAsync)}: Test gave back pair {Pair.PairId} in {usageTime.TotalMilliseconds} ms"); - var dirtyWatch = new Stopwatch(); - dirtyWatch.Start(); - Pair.Kill(); - PoolManager.NoCheckReturn(Pair); - var disposeTime = dirtyWatch.Elapsed; - await _testOut.WriteLineAsync($"{nameof(DisposeAsync)}: Disposed pair {Pair.PairId} 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."); - } - - private async Task OnCleanDispose() - { - var usageTime = UsageWatch.Elapsed; - await _testOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: Test borrowed pair {Pair.PairId} for {usageTime.TotalMilliseconds} ms"); - var cleanWatch = new Stopwatch(); - cleanWatch.Start(); - // Let any last minute failures the test cause happen. - await PoolManager.ReallyBeIdle(Pair); - if (!Pair.Settings.Destructive) - { - if (Pair.Client.IsAlive == false) - { - throw new Exception($"{nameof(CleanReturnAsync)}: Test killed the client in pair {Pair.PairId}:", Pair.Client.UnhandledException); - } - - if (Pair.Server.IsAlive == false) - { - throw new Exception($"{nameof(CleanReturnAsync)}: Test killed the server in pair {Pair.PairId}:", Pair.Server.UnhandledException); - } - } - - if (Pair.Settings.MustNotBeReused) - { - Pair.Kill(); - PoolManager.NoCheckReturn(Pair); - await PoolManager.ReallyBeIdle(Pair); - var returnTime2 = cleanWatch.Elapsed; - await _testOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: Clean disposed in {returnTime2.TotalMilliseconds} ms"); - return; - } - - var sRuntimeLog = Pair.Server.ResolveDependency(); - if (sRuntimeLog.ExceptionCount > 0) - throw new Exception($"{nameof(CleanReturnAsync)}: Server logged exceptions"); - var cRuntimeLog = Pair.Client.ResolveDependency(); - if (cRuntimeLog.ExceptionCount > 0) - throw new Exception($"{nameof(CleanReturnAsync)}: Client logged exceptions"); - - Pair.ClearContext(); - PoolManager.NoCheckReturn(Pair); - var returnTime = cleanWatch.Elapsed; - await _testOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: PoolManager took {returnTime.TotalMilliseconds} ms to put pair {Pair.PairId} back into the pool"); - } - - public async ValueTask CleanReturnAsync() - { - var disposed = Interlocked.Exchange(ref _disposed, 1); - switch (disposed) - { - case 0: - await _testOut.WriteLineAsync($"{nameof(CleanReturnAsync)}: Return of pair {Pair.PairId} started"); - break; - case 1: - throw new Exception($"{nameof(CleanReturnAsync)}: Already clean returned"); - case 2: - throw new Exception($"{nameof(CleanReturnAsync)}: Already dirty disposed"); - default: - throw new Exception($"{nameof(CleanReturnAsync)}: Unexpected disposed value"); - } - - await OnCleanDispose(); - } - - public async ValueTask DisposeAsync() - { - var disposed = Interlocked.Exchange(ref _disposed, 2); - switch (disposed) - { - case 0: - await _testOut.WriteLineAsync($"{nameof(DisposeAsync)}: Dirty return of pair {Pair.PairId} started"); - break; - case 1: - await _testOut.WriteLineAsync($"{nameof(DisposeAsync)}: Pair {Pair.PairId} was properly clean disposed"); - return; - case 2: - throw new Exception($"{nameof(DisposeAsync)}: Already dirty disposed pair {Pair.PairId}"); - default: - throw new Exception($"{nameof(DisposeAsync)}: Unexpected disposed value"); - } - await OnDirtyDispose(); - } -} +} \ No newline at end of file diff --git a/Content.IntegrationTests/PoolSettings.cs b/Content.IntegrationTests/PoolSettings.cs new file mode 100644 index 0000000000..a78173808f --- /dev/null +++ b/Content.IntegrationTests/PoolSettings.cs @@ -0,0 +1,117 @@ +#nullable enable + +namespace Content.IntegrationTests; + +/// +/// Settings for the pooled server, and client pair. +/// Some options are for changing the pair, and others are +/// so the pool can properly clean up what you borrowed. +/// +public sealed class PoolSettings +{ + /// + /// If the returned pair must not be reused + /// + public bool MustNotBeReused => Destructive || NoLoadContent || NoLoadTestPrototypes; + + /// + /// If the given pair must be brand new + /// + public bool MustBeNew => Fresh || NoLoadContent || NoLoadTestPrototypes; + + /// + /// Set to true if the test will ruin the server/client pair. + /// + public bool Destructive { get; init; } + + /// + /// Set to true if the given server/client pair should be created fresh. + /// + public bool Fresh { get; init; } + + /// + /// Set to true if the given server should be using a dummy ticker. Ignored if is true. + /// + public bool DummyTicker { get; init; } = true; + + public bool UseDummyTicker => !InLobby && DummyTicker; + + /// + /// If true, this enables the creation of admin logs during the test. + /// + public bool AdminLogsEnabled { 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. + /// If is true, this option is ignored. + /// + public bool Connected { get; init; } + + public bool ShouldBeConnected => InLobby || Connected; + + /// + /// Set to true if the given server/client pair should be in the lobby. + /// If the pair is not in the lobby at the end of the test, this test must be marked as dirty. + /// + /// + /// If this is enabled, the value of is ignored. + /// + public bool InLobby { get; init; } + + /// + /// Set this to true to skip loading the content files. + /// Note: This setting won't work with a client. + /// + public bool NoLoadContent { 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 prototypees. + /// + public bool NoLoadTestPrototypes { get; init; } + + /// + /// Set this to true to disable the NetInterp CVar on the given server/client pair + /// + public bool DisableInterpolate { get; init; } + + /// + /// Set this to true to always clean up the server/client pair before giving it to another borrower + /// + public bool Dirty { get; init; } + + /// + /// Set this to the path of a map to have the given server/client pair load the map. + /// + public string Map { get; init; } = PoolManager.TestMap; + + /// + /// Overrides the test name detection, and uses this in the test history instead + /// + public string? TestName { get; set; } + + /// + /// 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 bool CanFastRecycle(PoolSettings 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; + + // Check that certain settings match. + return !ShouldBeConnected == !nextSettings.ShouldBeConnected + && UseDummyTicker == nextSettings.UseDummyTicker + && Map == nextSettings.Map + && InLobby == nextSettings.InLobby; + } +} \ No newline at end of file diff --git a/Content.IntegrationTests/Tests/Commands/PardonCommand.cs b/Content.IntegrationTests/Tests/Commands/PardonCommand.cs index a6788b0ff7..a24985d738 100644 --- a/Content.IntegrationTests/Tests/Commands/PardonCommand.cs +++ b/Content.IntegrationTests/Tests/Commands/PardonCommand.cs @@ -146,7 +146,7 @@ namespace Content.IntegrationTests.Tests.Commands Assert.That(sPlayerManager.Sessions.Count(), Is.EqualTo(0)); client.SetConnectTarget(server); await client.WaitPost(() => netMan.ClientConnect(null!, 0, null!)); - await PoolManager.ReallyBeIdle(pairTracker.Pair); + await pairTracker.RunTicksSync(5); Assert.That(sPlayerManager.Sessions.Count(), Is.EqualTo(1)); await pairTracker.CleanReturnAsync(); diff --git a/Content.IntegrationTests/Tests/Interaction/InteractionTest.cs b/Content.IntegrationTests/Tests/Interaction/InteractionTest.cs index df1563419f..f59c7a2cfa 100644 --- a/Content.IntegrationTests/Tests/Interaction/InteractionTest.cs +++ b/Content.IntegrationTests/Tests/Interaction/InteractionTest.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Numerics; using Content.Client.Construction; using Content.Client.Examine; +using Content.IntegrationTests.Pair; using Content.Server.Body.Systems; using Content.Server.Mind; using Content.Server.Players; @@ -40,11 +41,11 @@ public abstract partial class InteractionTest { protected virtual string PlayerPrototype => "InteractionTestMob"; - protected PairTracker PairTracker = default!; - protected TestMapData MapData = default!; + protected TestPair PairTracker = default!; + protected TestMapData MapData => PairTracker.TestMap!; - protected RobustIntegrationTest.ServerIntegrationInstance Server => PairTracker.Pair.Server; - protected RobustIntegrationTest.ClientIntegrationInstance Client => PairTracker.Pair.Client; + protected RobustIntegrationTest.ServerIntegrationInstance Server => PairTracker.Server; + protected RobustIntegrationTest.ClientIntegrationInstance Client => PairTracker.Client; protected MapId MapId => MapData.MapId; @@ -172,7 +173,7 @@ public abstract partial class InteractionTest CLogger = Client.ResolveDependency().RootSawmill; // Setup map. - MapData = await PoolManager.CreateTestMap(PairTracker); + await PairTracker.CreateTestMap(); PlayerCoords = MapData.GridCoords.Offset(new Vector2(0.5f, 0.5f)).WithEntityId(MapData.MapUid, Transform, SEntMan); TargetCoords = MapData.GridCoords.Offset(new Vector2(1.5f, 0.5f)).WithEntityId(MapData.MapUid, Transform, SEntMan); await SetTile(Plating, grid: MapData.MapGrid); @@ -225,7 +226,7 @@ public abstract partial class InteractionTest }); // Final player asserts/checks. - await PoolManager.ReallyBeIdle(PairTracker.Pair, 5); + await PairTracker.ReallyBeIdle(5); Assert.Multiple(() => { Assert.That(cPlayerMan.LocalPlayer.ControlledEntity, Is.EqualTo(Player)); diff --git a/Content.IntegrationTests/Tests/Minds/MindTests.Helpers.cs b/Content.IntegrationTests/Tests/Minds/MindTests.Helpers.cs index 152715d471..d984b31b0e 100644 --- a/Content.IntegrationTests/Tests/Minds/MindTests.Helpers.cs +++ b/Content.IntegrationTests/Tests/Minds/MindTests.Helpers.cs @@ -1,4 +1,5 @@ using System.Linq; +using Content.IntegrationTests.Pair; using Content.Server.Ghost.Components; using Content.Server.Mind; using Content.Server.Players; @@ -23,7 +24,7 @@ public sealed partial class MindTests /// the player's mind's current entity, likely because some previous test directly changed the players attached /// entity. /// - private static async Task SetupPair(bool dirty = false) + private static async Task SetupPair(bool dirty = false) { var pairTracker = await PoolManager.GetServerClient(new PoolSettings { @@ -61,7 +62,7 @@ public sealed partial class MindTests return pairTracker; } - private static async Task BecomeGhost(Pair pair, bool visit = false) + private static async Task BecomeGhost(TestPair pair, bool visit = false) { var entMan = pair.Server.ResolveDependency(); var playerMan = pair.Server.ResolveDependency(); @@ -103,7 +104,7 @@ public sealed partial class MindTests return ghostUid; } - private static async Task VisitGhost(Pair pair, bool _ = false) + private static async Task VisitGhost(Pair.TestPair pair, bool _ = false) { return await BecomeGhost(pair, visit: true); } @@ -111,7 +112,7 @@ public sealed partial class MindTests /// /// Get the player's current mind and check that the entities exists. /// - private static Mind GetMind(Pair pair) + private static Mind GetMind(Pair.TestPair pair) { var playerMan = pair.Server.ResolveDependency(); var entMan = pair.Server.ResolveDependency(); @@ -130,7 +131,7 @@ public sealed partial class MindTests return mind; } - private static async Task Disconnect(Pair pair) + private static async Task Disconnect(Pair.TestPair pair) { var netManager = pair.Client.ResolveDependency(); var playerMan = pair.Server.ResolveDependency(); @@ -151,7 +152,7 @@ public sealed partial class MindTests }); } - private static async Task Connect(Pair pair, string username) + private static async Task Connect(Pair.TestPair pair, string username) { var netManager = pair.Client.ResolveDependency(); var playerMan = pair.Server.ResolveDependency(); @@ -166,7 +167,7 @@ public sealed partial class MindTests Assert.That(player.Status, Is.EqualTo(SessionStatus.InGame)); } - private static async Task DisconnectReconnect(Pair pair) + private static async Task DisconnectReconnect(Pair.TestPair pair) { var playerMan = pair.Server.ResolveDependency(); var player = playerMan.ServerSessions.Single(); diff --git a/Content.IntegrationTests/Tests/Networking/SimplePredictReconcileTest.cs b/Content.IntegrationTests/Tests/Networking/SimplePredictReconcileTest.cs index 8533c1aad0..2751e46026 100644 --- a/Content.IntegrationTests/Tests/Networking/SimplePredictReconcileTest.cs +++ b/Content.IntegrationTests/Tests/Networking/SimplePredictReconcileTest.cs @@ -60,8 +60,8 @@ namespace Content.IntegrationTests.Tests.Networking }); // Run some ticks and ensure that the buffer has filled up. - await PoolManager.SyncTicks(pairTracker.Pair); - await PoolManager.RunTicksSync(pairTracker.Pair, 25); + await pairTracker.SyncTicks(); + await pairTracker.RunTicksSync(25); Assert.That(cGameTiming.TickTimingAdjustment, Is.EqualTo(0)); Assert.That(sGameTiming.TickTimingAdjustment, Is.EqualTo(0)); diff --git a/Content.IntegrationTests/Tests/RoundEndTest.cs b/Content.IntegrationTests/Tests/RoundEndTest.cs index c307916841..0a6bf20153 100644 --- a/Content.IntegrationTests/Tests/RoundEndTest.cs +++ b/Content.IntegrationTests/Tests/RoundEndTest.cs @@ -137,8 +137,6 @@ namespace Content.IntegrationTests.Tests roundEndSystem.DefaultCountdownDuration = TimeSpan.FromMinutes(4); ticker.RestartRound(); }); - await PoolManager.ReallyBeIdle(pairTracker.Pair, 10); - await pairTracker.CleanReturnAsync(); } } diff --git a/Content.IntegrationTests/Tests/StackTest.cs b/Content.IntegrationTests/Tests/StackTest.cs index 54688f8ea4..9eeef87346 100644 --- a/Content.IntegrationTests/Tests/StackTest.cs +++ b/Content.IntegrationTests/Tests/StackTest.cs @@ -19,7 +19,7 @@ public sealed class StackTest Assert.Multiple(() => { - foreach (var entity in PoolManager.GetEntityPrototypes(server)) + foreach (var entity in PoolManager.GetPrototypesWithComponent(server)) { if (!entity.TryGetComponent(out var stackComponent, compFact) || !entity.TryGetComponent(out var itemComponent, compFact)) diff --git a/Content.IntegrationTests/Tests/StorageTest.cs b/Content.IntegrationTests/Tests/StorageTest.cs index f63bb9c399..61edd4eaa3 100644 --- a/Content.IntegrationTests/Tests/StorageTest.cs +++ b/Content.IntegrationTests/Tests/StorageTest.cs @@ -79,7 +79,7 @@ namespace Content.IntegrationTests.Tests Assert.Multiple(() => { - foreach (var proto in PoolManager.GetEntityPrototypes(server)) + foreach (var proto in PoolManager.GetPrototypesWithComponent(server)) { int capacity; var isEntStorage = false; diff --git a/Content.IntegrationTests/Tests/Toolshed/ToolshedTest.cs b/Content.IntegrationTests/Tests/Toolshed/ToolshedTest.cs index d04c6c6b7e..049c83084f 100644 --- a/Content.IntegrationTests/Tests/Toolshed/ToolshedTest.cs +++ b/Content.IntegrationTests/Tests/Toolshed/ToolshedTest.cs @@ -1,5 +1,6 @@ #nullable enable using System.Collections.Generic; +using Content.IntegrationTests.Pair; using Content.Server.Administration.Managers; using Robust.Server.Player; using Robust.Shared.Players; @@ -14,7 +15,7 @@ namespace Content.IntegrationTests.Tests.Toolshed; [FixtureLifeCycle(LifeCycle.InstancePerTestCase)] public abstract class ToolshedTest : IInvocationContext { - protected PairTracker PairTracker = default!; + protected TestPair PairTracker = default!; protected virtual bool Connected => false; protected virtual bool AssertOnUnexpectedError => true;