#nullable enable using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reflection; using System.Threading; using Content.IntegrationTests.Fixtures.Attributes; using Content.IntegrationTests.NUnit.Constraints; using Content.IntegrationTests.Pair; using Content.IntegrationTests.Utility; using NUnit.Framework.Interfaces; using Robust.Client.Timing; using Robust.Shared.GameObjects; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Timing; using Robust.Shared.Utility; using Robust.UnitTesting; namespace Content.IntegrationTests.Fixtures; /// /// /// A test fixture with an integrated test pair, /// proxy methods for efficient test writing, utilities for ensuring tests clean up correctly, /// and dependency injection (). /// /// /// Tests using GameTest support some additional class and method level attributes, namely /// . /// Attributes can be used to control how the test runs. /// /// /// /// [TestFixture] [FixtureLifeCycle(LifeCycle.InstancePerTestCase)] [Property(TestProperties.TestFrameKind, nameof(GameTest))] [SuppressMessage("Structure", "NUnit1028:The non-test method is public")] public abstract partial class GameTest { /// /// Set if the test manually marks itself dirty. /// private bool _pairDestroyed; /// /// Tests-testing-tests assistant to run right before the pair is returned. /// public event Action? PreFinalizeHook; /// /// The main thread of the game server. /// public Thread ServerThread { get; private set; } = null!; // NULLABILITY: This is always set during test setup. /// /// The main thread of the game client. /// public Thread ClientThread { get; private set; } = null!; // NULLABILITY: This is always set during test setup. /// /// Settings for the client/server pair. /// By default, this gets you a client and server that have connected together. /// /// /// Always return a new instance whenever this is read. In other words, no backing field please. Arrow syntax only. /// public virtual PoolSettings PoolSettings => new() { Connected = true }; /// /// The client and server pair. /// public TestPair Pair { get; private set; } = default!; // NULLABILITY: This is always set during test setup. /// /// The game server instance. /// public RobustIntegrationTest.ServerIntegrationInstance Server => Pair.Server; /// /// The game client instance. /// public RobustIntegrationTest.ClientIntegrationInstance Client => Pair.Client; /// /// The test player's server session, if any. /// public ICommonSession? ServerSession => Pair.Player; /// /// The server-side entity manager. /// [SidedDependency(Side.Server)] public IEntityManager SEntMan = null!; /// /// The client-side entity manager. /// [SidedDependency(Side.Client)] public IEntityManager CEntMan = null!; /// /// The server-side prototype manager. /// [SidedDependency(Side.Server)] public IPrototypeManager SProtoMan = null!; /// /// The client-side prototype manager. /// [SidedDependency(Side.Client)] public IPrototypeManager CProtoMan = null!; /// /// The server-side game-timing manager. /// [SidedDependency(Side.Server)] public IGameTiming SGameTiming = null!; /// /// The client-side game-timing manager. /// [SidedDependency(Side.Client)] public IClientGameTiming CGameTiming = null!; /// /// The test map we're using, if any. /// public TestMapData? TestMap => Pair.TestMap; private bool _setupDone = false; /// /// Primary setup task for the fixture. /// Custom setup must run after this. /// [SetUp] public virtual async Task DoSetup() { _pairDestroyed = false; var testContext = TestContext.CurrentContext; var test = testContext.Test; var settings = PoolSettings; var pairAttribs = test.Method!.GetCustomAttributes(false); var pairSuiteAttribs = test.Method!.TypeInfo.GetCustomAttributes(true); if (pairAttribs.Length > 1 && pairAttribs.Any(x => x.Exclusive)) { throw new InvalidOperationException( "More than one exclusive pair config attribute is present on the test member."); } if (pairSuiteAttribs.Length > 1 && pairSuiteAttribs.Any(x => x.Exclusive)) { throw new InvalidOperationException( "More than one exclusive pair config attribute is present on the test fixture."); } foreach (var attribute in pairSuiteAttribs.Concat(pairAttribs)) { attribute.ApplyToPairSettings(this, ref settings); } Pair = await PoolManager.GetServerClient(settings, new NUnitTestContextWrap(testContext, TestContext.Out)); Task.WaitAll( Server.WaitPost(() => ServerThread = Thread.CurrentThread), Client.WaitPost(() => ClientThread = Thread.CurrentThread) ); await Pair.ReallyBeIdle(5); // Arbitrary setup time wait. InjectDependencies(this); var attribs = test.Method!.GetCustomAttributes(false); var suiteAttribs = test.Method!.TypeInfo.GetCustomAttributes(true); foreach (var attribute in suiteAttribs.Concat(attribs)) { await attribute.ApplyToTest(this); } _setupDone = true; await DoPreTestOverrides(); await Pair.RunUntilSynced(); } /// /// Injects dependencies into the target object. /// /// /// This is called on the GameTest itself automatically. Don't call it twice on the same object. /// /// The object to inject into. public void InjectDependencies(object target) { foreach (var field in target.GetType().GetAllFields()) { if (field.GetCustomAttribute() is { } depAttrib) { // ReSharper disable once ConvertIfStatementToConditionalTernaryExpression if (depAttrib.Side is Side.Server) { field.SetValue(target, Server.EntMan.EntitySysManager.DependencyCollection.ResolveType(field.FieldType)); } else { // Must be initially connected for this... if (Client.Session is not null) field.SetValue(target, Client.EntMan.EntitySysManager.DependencyCollection.ResolveType(field.FieldType)); else field.SetValue(target, Client.InstanceDependencyCollection.ResolveType(field.FieldType)); } } } } /// /// Primary teardown task for the fixture. /// Custom teardown must run before this. /// [TearDown] public virtual async Task DoTeardown() { try { // In some cool future we might be able to make this only throw out the pair // if the test threw exceptions. But that'd require fixing all of them to do cleanup properly on failure. // // So not yet. if (TestContext.CurrentContext.Result.Outcome.Status == TestStatus.Failed) { _pairDestroyed = true; // Blow it up, we failed and it might be screwed. return; } // Roll forward til sync for teardown. await Pair.RunUntilSynced(); await CleanUpEntities(); // And other teardown logic will go here. Eventually. } catch (Exception) { _pairDestroyed = true; throw; } finally { PreFinalizeHook?.Invoke(); if (!_pairDestroyed) await Pair.CleanReturnAsync(); else await Pair.DisposeAsync(); } } }