diff --git a/Content.IntegrationTests/Fixtures/Attributes/EnsureCVarAttribute.cs b/Content.IntegrationTests/Fixtures/Attributes/EnsureCVarAttribute.cs
new file mode 100644
index 00000000000..50da744ecfd
--- /dev/null
+++ b/Content.IntegrationTests/Fixtures/Attributes/EnsureCVarAttribute.cs
@@ -0,0 +1,74 @@
+#nullable enable
+using System.Reflection;
+using NUnit.Framework.Interfaces;
+using NUnit.Framework.Internal;
+using Robust.Shared.Configuration;
+
+namespace Content.IntegrationTests.Fixtures.Attributes;
+
+///
+/// Ensures the given CVar, on the given side (or both), is the given value.
+/// Attribute version of , and stores the old value the same way.
+///
+/// This only works with fixtures.
+/// The side to set the CVar on, or both.
+/// The type the CVar is defined on.
+/// The name of the static field defining the CVar.
+/// The value to set the CVar to.
+///
+///
+/// [Test]
+/// [EnsureCVar(Side.Server, typeof(CCVars), nameof(CCVars.FlavorText), true)]
+/// public async Task MyTest()
+/// {
+/// // CVar is set for you inside the test, and automatically un-set on teardown.
+/// }
+///
+///
+///
+[AttributeUsage(AttributeTargets.Method | AttributeTargets.Class)]
+public sealed class EnsureCVarAttribute(Side side, Type definitionType, string fieldName, object value) : Attribute, IGameTestModifier, IApplyToTest
+{
+ public const string ClientEnsuredCVarsProperty = "ClientEnsuredCVars";
+ public const string ServerEnsuredCVarsProperty = "ServerEnsuredCVars";
+
+ Task IGameTestModifier.ApplyToTest(GameTest test)
+ {
+ var cvar = LookupCVar();
+
+ test.PreTestAddOverride(side, cvar.Name, value);
+
+ return Task.CompletedTask;
+ }
+
+ private CVarDef LookupCVar()
+ {
+ var field = definitionType.GetField(fieldName, BindingFlags.Static | BindingFlags.Public);
+ if (field is null)
+ throw new ArgumentException($"Couldn't find a public, static field named {fieldName} on {definitionType}");
+
+ var obj = field.GetValue(null);
+
+ if (obj is not CVarDef cvar)
+ {
+ throw new ArgumentException(
+ $"Expected a CVar definition on {definitionType}.{fieldName}, but it was a {obj?.GetType().FullName ?? "null"}");
+ }
+
+ if (value.GetType() != cvar.DefaultValue.GetType())
+ throw new NotSupportedException($"Cannot set {cvar.Name} to {value}, it's the wrong type.");
+
+ return cvar;
+ }
+
+ void IApplyToTest.ApplyToTest(Test test)
+ {
+ var cvar = LookupCVar();
+
+ if ((side & Side.Client) != 0)
+ test.Properties.Add(ClientEnsuredCVarsProperty, $"{cvar.Name} = {value}");
+
+ if ((side & Side.Server) != 0)
+ test.Properties.Add(ServerEnsuredCVarsProperty, $"{cvar.Name} = {value}");
+ }
+}
diff --git a/Content.IntegrationTests/Fixtures/Attributes/IGameTestModifier.cs b/Content.IntegrationTests/Fixtures/Attributes/IGameTestModifier.cs
new file mode 100644
index 00000000000..70cd904f3fe
--- /dev/null
+++ b/Content.IntegrationTests/Fixtures/Attributes/IGameTestModifier.cs
@@ -0,0 +1,20 @@
+namespace Content.IntegrationTests.Fixtures.Attributes;
+
+///
+/// Marks an attribute as a modifier for fixtures.
+/// These attributes can be applied to both test methods and fixtures.
+///
+///
+/// GameTest modifiers are encouraged to also implement IApplyToTest and add properties to the test
+/// indicating their presence.
+///
+public interface IGameTestModifier
+{
+ ///
+ /// Method called by GameTest on itself when applying modifiers.
+ ///
+ /// The test being modified
+ /// Async task to await.
+ Task ApplyToTest(GameTest test);
+}
+
diff --git a/Content.IntegrationTests/Fixtures/Attributes/IGameTestPairConfigModifier.cs b/Content.IntegrationTests/Fixtures/Attributes/IGameTestPairConfigModifier.cs
new file mode 100644
index 00000000000..e47b99b1463
--- /dev/null
+++ b/Content.IntegrationTests/Fixtures/Attributes/IGameTestPairConfigModifier.cs
@@ -0,0 +1,25 @@
+#nullable enable
+
+namespace Content.IntegrationTests.Fixtures.Attributes;
+
+///
+/// Interface used for pair configuration attributes.
+/// This allows such attributes to modify the pair settings, and also describe what parts of pairs they modify
+/// so odd configuration choices can be spotted.
+///
+public interface IGameTestPairConfigModifier
+{
+ ///
+ /// Whether this modifier is exclusive and should conflict with other exclusive modifiers.
+ /// Essentially, fail immediately if other IGameTestPairConfigModifier attributes are present if this is set.
+ ///
+ bool Exclusive { get; }
+
+ ///
+ /// Called when GameTest needs its modified by the modifier.
+ ///
+ /// The test we're applying to.
+ /// The settings object to modify.
+ void ApplyToPairSettings(GameTest test, ref PoolSettings settings);
+}
+
diff --git a/Content.IntegrationTests/Fixtures/Attributes/PairConfigAttribute.cs b/Content.IntegrationTests/Fixtures/Attributes/PairConfigAttribute.cs
new file mode 100644
index 00000000000..d1975399973
--- /dev/null
+++ b/Content.IntegrationTests/Fixtures/Attributes/PairConfigAttribute.cs
@@ -0,0 +1,53 @@
+#nullable enable
+using System.Reflection;
+
+namespace Content.IntegrationTests.Fixtures.Attributes;
+
+///
+/// Configures the test pair using settings from the given type (by default the current test) and static property member.
+///
+/// The type to look up the member on, if any.
+/// The static property to read the settings from.
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
+public sealed class PairConfigAttribute(Type? sourceType, string sourceMember) : Attribute, IGameTestPairConfigModifier
+{
+ public bool Exclusive => true;
+
+ public readonly Type? SourceType = sourceType;
+ public readonly string SourceMember = sourceMember;
+
+ private const BindingFlags PropertyBindingFlags = BindingFlags.Static
+ | BindingFlags.Public
+ | BindingFlags.NonPublic
+ | BindingFlags.FlattenHierarchy;
+
+ public PairConfigAttribute(string sourceMember) : this(null, sourceMember)
+ {
+ }
+
+ public void ApplyToPairSettings(GameTest test, ref PoolSettings settings)
+ {
+ var sourceType = SourceType ?? test.GetType();
+
+ var property = sourceType.GetProperty(SourceMember, PropertyBindingFlags);
+
+ if (property is null)
+ {
+ if (sourceType.GetField(SourceMember, PropertyBindingFlags) is not null)
+ {
+ throw new ArgumentException(
+ $"Couldn't find static property {SourceMember} on {sourceType.Name}, but could find a field. Only properties are allowed.");
+ }
+
+ throw new ArgumentException($"Couldn't find static property {SourceMember} on {sourceType.Name}");
+ }
+
+ if (!property.PropertyType.IsAssignableTo(typeof(PoolSettings)))
+ {
+ throw new ArgumentException(
+ $"{sourceType.Name}.{SourceMember} is not assignable to {nameof(PoolSettings)} and cannot be used.");
+ }
+
+ settings = (PoolSettings)property.GetValue(null)!;
+ }
+}
diff --git a/Content.IntegrationTests/Fixtures/Attributes/RunOnSideAttribute.cs b/Content.IntegrationTests/Fixtures/Attributes/RunOnSideAttribute.cs
new file mode 100644
index 00000000000..75a22e93341
--- /dev/null
+++ b/Content.IntegrationTests/Fixtures/Attributes/RunOnSideAttribute.cs
@@ -0,0 +1,79 @@
+#nullable enable
+using Content.IntegrationTests.NUnit.Utilities;
+using NUnit.Framework.Interfaces;
+using NUnit.Framework.Internal;
+using NUnit.Framework.Internal.Commands;
+
+namespace Content.IntegrationTests.Fixtures.Attributes;
+
+///
+/// Ensures a test method runs on the given side (client or server, not neither nor both).
+///
+///
+/// This only works for fixtures.
+///
+///
+[AttributeUsage(AttributeTargets.Method)]
+public sealed class RunOnSideAttribute : Attribute, IWrapTestMethod, IImplyFixture, IApplyToTest
+{
+ public const string RunOnSideProperty = "RanOnSide";
+
+ ///
+ /// Which side to run the inner test code on, if not the test thread.
+ ///
+ public Side RunOnSide { get; }
+
+ public RunOnSideAttribute(Side side)
+ {
+ RunOnSide = side;
+ if (side is not Side.Client and not Side.Server)
+ throw new NotSupportedException("Test run-on-side can only be the client or server, not both or neither.");
+ }
+
+ TestCommand ICommandWrapper.Wrap(TestCommand command)
+ {
+ return new SidedTestCommand(command, RunOnSide);
+ }
+
+ private sealed class SidedTestCommand : DelegatingTestCommand
+ {
+ private readonly Side _side;
+
+ public SidedTestCommand(TestCommand inner, Side side) : base(inner)
+ {
+ _side = side;
+ }
+
+ public override TestResult Execute(TestExecutionContext context)
+ {
+ innerCommand.Test.EnsureFixtureIsGameTest(typeof(RunOnSideAttribute), out var gt);
+
+ if (_side is not Side.Client and not Side.Server)
+ throw new NotSupportedException($"Sided tests need to specify a specific side. {Test}");
+
+ if (_side is Side.Client)
+ {
+ gt.Client.WaitAssertion(() =>
+ {
+ context.CurrentResult = innerCommand.Execute(context);
+ })
+ .Wait();
+ }
+ else
+ {
+ gt.Server.WaitAssertion(() =>
+ {
+ context.CurrentResult = innerCommand.Execute(context);
+ })
+ .Wait();
+ }
+
+ return context.CurrentResult;
+ }
+ }
+
+ public void ApplyToTest(Test test)
+ {
+ test.Properties.Add(RunOnSideProperty, RunOnSide.ToString());
+ }
+}
diff --git a/Content.IntegrationTests/Fixtures/Attributes/Side.cs b/Content.IntegrationTests/Fixtures/Attributes/Side.cs
new file mode 100644
index 00000000000..06f1e90acb1
--- /dev/null
+++ b/Content.IntegrationTests/Fixtures/Attributes/Side.cs
@@ -0,0 +1,27 @@
+#nullable enable
+namespace Content.IntegrationTests.Fixtures.Attributes;
+
+///
+/// A flag enum representing a side of a testpair.
+///
+[Flags]
+public enum Side : byte
+{
+ ///
+ /// Bitflag representing the client side of a testpair.
+ ///
+ Client = 1,
+ ///
+ /// Bitflag representing the server side of a testpair.
+ ///
+ Server = 2,
+
+ ///
+ /// A value indicating no side was specified. You shouldn't use this outside of checking for it as an error.
+ ///
+ Neither = 0,
+ ///
+ /// A value indicating both sides were specified.
+ ///
+ Both = Client | Server,
+}
diff --git a/Content.IntegrationTests/Fixtures/Attributes/SidedDependencyAttribute.cs b/Content.IntegrationTests/Fixtures/Attributes/SidedDependencyAttribute.cs
new file mode 100644
index 00000000000..00fe7a952f8
--- /dev/null
+++ b/Content.IntegrationTests/Fixtures/Attributes/SidedDependencyAttribute.cs
@@ -0,0 +1,23 @@
+#nullable enable
+
+namespace Content.IntegrationTests.Fixtures.Attributes;
+
+///
+/// Marks a field on a fixture as needing to be populated with an IoC dependency from the given side.
+///
+///
+[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)]
+public sealed class SidedDependencyAttribute : Attribute
+{
+ public SidedDependencyAttribute(Side side)
+ {
+ Side = side;
+
+ if (side is not Side.Client and not Side.Server)
+ {
+ throw new NotSupportedException($"Expected either the client or the server as a side, got {side}.");
+ }
+ }
+
+ public Side Side { get; }
+}
diff --git a/Content.IntegrationTests/Fixtures/Attributes/TrackingIssueAttribute.cs b/Content.IntegrationTests/Fixtures/Attributes/TrackingIssueAttribute.cs
new file mode 100644
index 00000000000..02f9e4f7e41
--- /dev/null
+++ b/Content.IntegrationTests/Fixtures/Attributes/TrackingIssueAttribute.cs
@@ -0,0 +1,53 @@
+using System.Diagnostics.CodeAnalysis;
+using System.Text.RegularExpressions;
+
+namespace Content.IntegrationTests.Fixtures.Attributes;
+
+///
+///
+/// An attribute meant to attach an issue (usually related to the test) to a given test or test fixture.
+/// This sets the TrackingIssue property on the test, and helps developers find why a test exists or why it
+/// is broken.
+///
+///
+/// This attribute should be used if a test corresponds directly to a bug in some way, either demonstrating it or
+/// ensuring it remains fixed. Only URLs should be provided, lone issue numbers are not accepted.
+///
+///
+/// If the bug was never given an issue, the fix PR containing the test is another acceptable thing to link, and the
+/// PR should clearly explain the bug it is fixing for future readers.
+///
+///
+public sealed class TrackingIssueAttribute : PropertyAttribute
+{
+ ///
+ /// Domains we allow for tracking issues, to avoid people putting discord or discourse links.
+ ///
+ private static readonly string[] _validDomains =
+ [
+ "github.com"
+ ];
+
+ private static readonly Regex GithubStyleIssueMatch = new(@"^\/[a-z\-\$\#]*\/[a-z\-\$\#]*\/(issues|pulls)\/\d*$",
+ RegexOptions.Compiled | RegexOptions.NonBacktracking | RegexOptions.IgnoreCase);
+
+ public TrackingIssueAttribute([StringSyntax(StringSyntaxAttribute.Uri)] string url) : base(url)
+ {
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
+ throw new ArgumentException($"Expected a valid URL for {nameof(TrackingIssueAttribute)}, got {url}");
+
+ // Assert the domain is reasonable.
+ if (!_validDomains.Contains(uri.Host, StringComparer.InvariantCultureIgnoreCase))
+ {
+ throw new ArgumentException(
+ $"Didn't recognize the domain used for the tracking issue, got {uri.Host}. We support: {string.Join(", ", _validDomains)}");
+ }
+
+ // Assert that the URL is reasonable.
+ if (!GithubStyleIssueMatch.IsMatch(uri.AbsolutePath))
+ {
+ throw new ArgumentException(
+ $"Didn't recognize the provided github link, it should point to a specific pull request or issue. Got {uri.AbsolutePath}");
+ }
+ }
+}
diff --git a/Content.IntegrationTests/Fixtures/GameTest.CVars.cs b/Content.IntegrationTests/Fixtures/GameTest.CVars.cs
new file mode 100644
index 00000000000..6ecde6fb6df
--- /dev/null
+++ b/Content.IntegrationTests/Fixtures/GameTest.CVars.cs
@@ -0,0 +1,83 @@
+#nullable enable
+using System.Collections.Generic;
+using Content.IntegrationTests.Fixtures.Attributes;
+using Robust.Shared.Configuration;
+
+namespace Content.IntegrationTests.Fixtures;
+
+// REMARK: You may be wondering why this doesn't bother storing the old CVars.
+// This is because TestPair actually has some not-well-known functionality to
+// automatically restore CVars to what they were pre-test for you.
+//
+// So instead of rolling that twice, this lets TestPair handle it.
+public abstract partial class GameTest
+{
+ [SidedDependency(Side.Server)] private readonly IConfigurationManager _serverCfg = default!;
+ [SidedDependency(Side.Client)] private readonly IConfigurationManager _clientCfg = default!;
+
+ private readonly Dictionary _clientCVarOverrides = new();
+ private readonly Dictionary _serverCVarOverrides = new();
+
+ ///
+ /// Adds a setup-time override for a given cvar, for use by s.
+ ///
+ public void PreTestAddOverride(Side side, string cVar, object value)
+ {
+ // ReSharper disable once ConditionIsAlwaysTrueOrFalse
+ if (_setupDone)
+ throw new NotSupportedException("Cannot use PreTest functions after test SetUp.");
+
+ if (side is Side.Neither)
+ throw new NotSupportedException($"Must specify a side, or both, for {nameof(PreTestAddOverride)}");
+
+ if ((side & Side.Server) != 0)
+ _serverCVarOverrides.Add(cVar, value);
+
+ if ((side & Side.Client) != 0)
+ _clientCVarOverrides.Add(cVar, value);
+ }
+
+ private async Task DoPreTestOverrides()
+ {
+ foreach (var (cvar, value) in _clientCVarOverrides)
+ {
+ await OverrideCVarByName(Side.Client, cvar, value, false);
+ }
+
+ foreach (var (cvar, value) in _serverCVarOverrides)
+ {
+ await OverrideCVarByName(Side.Server, cvar, value, false);
+ }
+
+ await Pair.RunUntilSynced();
+ }
+
+ ///
+ /// Sets a given CVar for the provided side.
+ ///
+ /// Does its own cleanup, you do not need to set the CVar back yourself.
+ public async Task OverrideCVar(Side side, CVarDef cvar, T value, bool sync = true)
+ where T: notnull
+ {
+ await OverrideCVarByName(side, cvar.Name, value, sync);
+ }
+
+ private async Task OverrideCVarByName(Side side, string cVar, object value, bool sync)
+ {
+ if (side is Side.Client)
+ {
+ _clientCfg.SetCVar(cVar, value);
+ }
+ else if (side is Side.Server)
+ {
+ _serverCfg.SetCVar(cVar, value);
+ }
+ else
+ {
+ throw new NotSupportedException($"Expected a specific side, got {side}.");
+ }
+
+ if (sync)
+ await Pair.RunUntilSynced();
+ }
+}
diff --git a/Content.IntegrationTests/Fixtures/GameTest.CommonPoolSettings.cs b/Content.IntegrationTests/Fixtures/GameTest.CommonPoolSettings.cs
new file mode 100644
index 00000000000..c6525192c57
--- /dev/null
+++ b/Content.IntegrationTests/Fixtures/GameTest.CommonPoolSettings.cs
@@ -0,0 +1,12 @@
+namespace Content.IntegrationTests.Fixtures;
+
+public abstract partial class GameTest
+{
+ ///
+ /// All-default-settings PoolSettings, with the client and server disconnected.
+ ///
+ protected static PoolSettings PsDisconnected => new()
+ {
+ Connected = false,
+ };
+}
diff --git a/Content.IntegrationTests/Fixtures/GameTest.Entities.cs b/Content.IntegrationTests/Fixtures/GameTest.Entities.cs
new file mode 100644
index 00000000000..2afdeec8ef5
--- /dev/null
+++ b/Content.IntegrationTests/Fixtures/GameTest.Entities.cs
@@ -0,0 +1,228 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Diagnostics.CodeAnalysis;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Map;
+
+namespace Content.IntegrationTests.Fixtures;
+
+public abstract partial class GameTest
+{
+ ///
+ /// Contains all server entities spawned using GameTest proxy methods.
+ ///
+ private readonly List _serverEntitiesToClean = new();
+
+ ///
+ /// Contains all client entities spawned using GameTest proxy methods.
+ ///
+ private readonly List _clientEntitiesToClean = new();
+
+ private async Task CleanUpEntities()
+ {
+ await Task.WhenAll(
+ Server.WaitAssertion(() =>
+ {
+ foreach (var junk in _serverEntitiesToClean)
+ {
+ if (!SEntMan.Deleted(junk))
+ SEntMan.DeleteEntity(junk);
+ }
+ }),
+ Client.WaitAssertion(() =>
+ {
+ foreach (var junk in _clientEntitiesToClean)
+ {
+ if (!CEntMan.Deleted(junk))
+ CEntMan.DeleteEntity(junk);
+ }
+ })
+ );
+ }
+
+ ///
+ /// Returns a string representation of an entity for the server.
+ ///
+ public string SToPrettyString(EntityUid uid)
+ {
+ return Pair.Server.EntMan.ToPrettyString(uid);
+ }
+
+ ///
+ /// Returns a string representation of an entity for the client.
+ ///
+ public string CToPrettyString(EntityUid uid)
+ {
+ return Pair.Client.EntMan.ToPrettyString(uid);
+ }
+
+ ///
+ /// Converts a server EntityUid into the client-side equivalent entity.
+ ///
+ public EntityUid ToClientUid(EntityUid serverUid)
+ {
+ return Pair.ToClientUid(serverUid);
+ }
+
+ ///
+ /// Converts a client EntityUid into the server-side equivalent entity.
+ ///
+ public EntityUid ToServerUid(EntityUid clientUid)
+ {
+ return Pair.ToServerUid(clientUid);
+ }
+
+ ///
+ /// Retrieves the given component from an entity on the server.
+ ///
+ public T SComp(EntityUid target)
+ where T : IComponent
+ {
+ return SEntMan.GetComponent(target);
+ }
+
+ ///
+ /// Attempts to retrieve the given component from an entity on the server.
+ ///
+ public bool STryComp(EntityUid? target, [NotNullWhen(true)] out T? component)
+ where T : IComponent
+ {
+ return SEntMan.TryGetComponent(target, out component);
+ }
+
+ ///
+ /// Retrieves the given component from an entity on the client.
+ ///
+ public T CComp(EntityUid target)
+ where T : IComponent
+ {
+ return CEntMan.GetComponent(target);
+ }
+
+ ///
+ /// Attempts to retrieve the given component from an entity on the server.
+ ///
+ public bool CTryComp(EntityUid? target, [NotNullWhen(true)] out T? component)
+ where T : IComponent
+ {
+ return SEntMan.TryGetComponent(target, out component);
+ }
+
+ ///
+ /// Pairs an EntityUid with the given component, from the server.
+ ///
+ public Entity SEntity(EntityUid target)
+ where T : IComponent
+ {
+ return new(target, SEntMan.GetComponent(target));
+ }
+
+ ///
+ /// Pairs an EntityUid with the given component, from the client.
+ ///
+ public Entity CEntity(EntityUid target)
+ where T : IComponent
+ {
+ return new(target, CEntMan.GetComponent(target));
+ }
+
+ ///
+ /// Spawns an entity on the server.
+ ///
+ /// This tracks the entity for post-test cleanup.
+ public EntityUid SSpawn(string? id)
+ {
+ var res = SEntMan.Spawn(id);
+ _serverEntitiesToClean.Add(res);
+ return res;
+ }
+
+ ///
+ /// Spawns an entity on the server at a location.
+ ///
+ /// This tracks the entity for post-test cleanup.
+ public EntityUid SSpawnAtPosition(string? id, EntityCoordinates coordinates)
+ {
+ var res = SEntMan.SpawnAtPosition(id, coordinates);
+ _serverEntitiesToClean.Add(res);
+ return res;
+ }
+
+ ///
+ /// Spawns an entity on the client.
+ ///
+ /// This tracks the entity for post-test cleanup.
+ public EntityUid CSpawn(string? id)
+ {
+ var res = CEntMan.Spawn(id);
+ _clientEntitiesToClean.Add(res);
+ return res;
+ }
+
+ ///
+ /// Spawns an entity on the server at a location.
+ ///
+ /// This tracks the entity for post-test cleanup.
+ public EntityUid CSpawnAtPosition(string? id, EntityCoordinates coordinates)
+ {
+ var res = CEntMan.SpawnAtPosition(id, coordinates);
+ _clientEntitiesToClean.Add(res);
+ return res;
+ }
+
+ ///
+ /// Asynchronously spawns an entity on the server.
+ ///
+ public async Task Spawn(string? id)
+ {
+ var ent = EntityUid.Invalid;
+
+ await Server.WaitPost(() => ent = SSpawn(id));
+
+ return ent;
+ }
+
+ ///
+ /// Asynchronously spawns an entity on the server at the given position.
+ ///
+ public async Task SpawnAtPosition(string? id, EntityCoordinates coords)
+ {
+ var ent = EntityUid.Invalid;
+
+ await Server.WaitPost(() => ent = SSpawnAtPosition(id, coords));
+
+ return ent;
+ }
+
+ ///
+ /// Deletes an entity on the server immediately.
+ ///
+ public void SDeleteNow(EntityUid id)
+ {
+ SEntMan.DeleteEntity(id);
+ }
+
+ ///
+ /// Deletes an entity on the client immediately.
+ ///
+ public void CDeleteNow(EntityUid id)
+ {
+ CEntMan.DeleteEntity(id);
+ }
+
+ ///
+ /// Queues an entity for deletion at the end of the tick on the server.
+ ///
+ public void SQueueDel(EntityUid id)
+ {
+ SEntMan.QueueDeleteEntity(id);
+ }
+
+ ///
+ /// Queues an entity for deletion at the end of the tick on the client.
+ ///
+ public void CQueueDel(EntityUid id)
+ {
+ CEntMan.QueueDeleteEntity(id);
+ }
+}
diff --git a/Content.IntegrationTests/Fixtures/GameTest.Pair.cs b/Content.IntegrationTests/Fixtures/GameTest.Pair.cs
new file mode 100644
index 00000000000..8d3a88bb7cf
--- /dev/null
+++ b/Content.IntegrationTests/Fixtures/GameTest.Pair.cs
@@ -0,0 +1,36 @@
+namespace Content.IntegrationTests.Fixtures;
+
+public abstract partial class GameTest
+{
+ ///
+ /// Runs the client and server for the given number of ticks, in lockstep.
+ ///
+ ///
+ /// Do not use this as a barrier for client-server synchronization, use .
+ ///
+ public Task RunTicksSync(int ticks)
+ {
+ return Pair.RunTicksSync(ticks);
+ }
+
+ ///
+ /// Runs the pairs just long enough for PVS to send entities, ensuring the client's current tick is what the
+ /// server's was at call time.
+ ///
+ public async Task RunUntilSynced()
+ {
+ await Pair.RunUntilSynced();
+ }
+
+ ///
+ /// Runs the test pair for a number of (simulated) seconds.
+ ///
+ ///
+ /// Does not actually take N seconds to evaluate, the game ticks as fast as possible.
+ /// Do not use this as a barrier for client-server synchronization, use .
+ ///
+ public Task RunSeconds(float seconds)
+ {
+ return Pair.RunSeconds(seconds);
+ }
+}
diff --git a/Content.IntegrationTests/Fixtures/GameTest.cs b/Content.IntegrationTests/Fixtures/GameTest.cs
new file mode 100644
index 00000000000..55903cf4a27
--- /dev/null
+++ b/Content.IntegrationTests/Fixtures/GameTest.cs
@@ -0,0 +1,265 @@
+#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();
+ }
+ }
+}
diff --git a/Content.IntegrationTests/NUnit/Constraints/CompConstraint.cs b/Content.IntegrationTests/NUnit/Constraints/CompConstraint.cs
new file mode 100644
index 00000000000..c474d0c2d55
--- /dev/null
+++ b/Content.IntegrationTests/NUnit/Constraints/CompConstraint.cs
@@ -0,0 +1,33 @@
+using NUnit.Framework.Constraints;
+using NUnit.Framework.Internal;
+using Robust.UnitTesting;
+
+namespace Content.IntegrationTests.NUnit.Constraints;
+
+///
+/// A prefix constraint like , for entity components.
+///
+///
+public sealed class CompConstraint(Type tComp, IIntegrationInstance instance, IConstraint baseConstraint)
+ : PrefixConstraint(baseConstraint, $"component {tComp.Name}")
+{
+ public override ConstraintResult ApplyTo(TActual actual)
+ {
+ if (!ConstraintHelpers.TryActualAsEnt(actual, instance, out var ent, out var error))
+ {
+ if (error)
+ {
+ throw new NotImplementedException(
+ $"The input type {typeof(TActual)} to {nameof(CompExistsConstraint)} is not a supported entity id.");
+ }
+
+ return new ConstraintResult(this, actual, ConstraintStatus.Failure);
+ }
+
+ if (!instance.EntMan.TryGetComponent(ent, tComp, out var comp))
+ return new ConstraintResult(this, actual, ConstraintStatus.Failure);
+
+ var baseResult = Reflect.InvokeApplyTo(constraint: baseConstraint, tComp, comp);
+ return new ConstraintResult(this, baseResult.ActualValue, baseResult.Status);
+ }
+}
diff --git a/Content.IntegrationTests/NUnit/Constraints/CompConstraintExtensions.cs b/Content.IntegrationTests/NUnit/Constraints/CompConstraintExtensions.cs
new file mode 100644
index 00000000000..1586c52e03f
--- /dev/null
+++ b/Content.IntegrationTests/NUnit/Constraints/CompConstraintExtensions.cs
@@ -0,0 +1,48 @@
+#nullable enable
+using Content.IntegrationTests.NUnit.Operators;
+using NUnit.Framework.Constraints;
+using Robust.Shared.GameObjects;
+using Robust.UnitTesting;
+
+namespace Content.IntegrationTests.NUnit.Constraints;
+
+///
+/// Provides Has.Comp<T>(side),
+/// a constraint that allows you to check for the presence of, or operate on, a component.
+///
+///
+///
+/// // Assert that the server sided entity myEntity has ItemComponent on the server.
+/// Assert.That(myEntity, Has.Comp<ItemComponent>(Server));
+///
+///
+public static class CompConstraintExtensions
+{
+ extension(Has)
+ {
+ public static ResolvableConstraintExpression Comp(IIntegrationInstance instance)
+ where T : IComponent
+ {
+ return new ConstraintExpression().Comp(instance);
+ }
+
+ public static ResolvableConstraintExpression Comp(Type t, IIntegrationInstance instance)
+ {
+ return new ConstraintExpression().Comp(t, instance);
+ }
+ }
+
+ extension(ConstraintExpression expr)
+ {
+ public ResolvableConstraintExpression Comp(IIntegrationInstance instance)
+ where T : IComponent
+ {
+ return expr.Append(new CompOperator(typeof(T), instance));
+ }
+
+ public ResolvableConstraintExpression Comp(Type t, IIntegrationInstance instance)
+ {
+ return expr.Append(new CompOperator(t, instance));
+ }
+ }
+}
diff --git a/Content.IntegrationTests/NUnit/Constraints/CompExistsConstraint.cs b/Content.IntegrationTests/NUnit/Constraints/CompExistsConstraint.cs
new file mode 100644
index 00000000000..eaff1eae8ad
--- /dev/null
+++ b/Content.IntegrationTests/NUnit/Constraints/CompExistsConstraint.cs
@@ -0,0 +1,30 @@
+#nullable enable
+using NUnit.Framework.Constraints;
+using Robust.UnitTesting;
+
+namespace Content.IntegrationTests.NUnit.Constraints;
+
+///
+/// Constraint for whether a component exists.
+///
+///
+public sealed class CompExistsConstraint(Type component, IIntegrationInstance instance) : Constraint
+{
+ public override ConstraintResult ApplyTo(TActual actual)
+ {
+ if (!ConstraintHelpers.TryActualAsEnt(actual, instance, out var ent, out var error))
+ {
+ if (error)
+ {
+ throw new NotImplementedException(
+ $"The input type {typeof(TActual)} to {nameof(CompExistsConstraint)} is not a supported entity id.");
+ }
+
+ return new ConstraintResult(this, actual, ConstraintStatus.Failure);
+ }
+
+ return new ConstraintResult(this, actual, instance.EntMan.HasComponent(ent, component));
+ }
+
+ public override string Description => $"has the component {component.Name}";
+}
diff --git a/Content.IntegrationTests/NUnit/Constraints/ConstraintHelpers.cs b/Content.IntegrationTests/NUnit/Constraints/ConstraintHelpers.cs
new file mode 100644
index 00000000000..f7f810eda41
--- /dev/null
+++ b/Content.IntegrationTests/NUnit/Constraints/ConstraintHelpers.cs
@@ -0,0 +1,66 @@
+#nullable enable
+using System.Diagnostics.CodeAnalysis;
+using Content.IntegrationTests.NUnit.Utilities;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Toolshed.TypeParsers;
+using Robust.UnitTesting;
+
+namespace Content.IntegrationTests.NUnit.Constraints;
+
+public static class ConstraintHelpers
+{
+ ///
+ /// A constraint implementation helper to convert TActual into an entityuid.
+ ///
+ /// The input value to try to get an entity uid from.
+ /// The integration test instance to resolve the entity from.
+ /// The resulting entity uid.
+ /// Whether TActual is recognized to begin with.
+ /// The type to cast out of.
+ public static bool TryActualAsEnt(TActual t, IIntegrationInstance instance, [NotNullWhen(true)] out EntityUid? ent, out bool validType)
+ {
+ if (t is EntityUid u)
+ {
+ ent = u;
+ validType = false;
+ return true;
+ }
+
+ if (t is IAsType asTy)
+ {
+ ent = asTy.AsType();
+ validType = false;
+ return true;
+ }
+
+ if (t is IResolvesToEntity resolvable)
+ {
+ if (instance is IServerIntegrationInstance)
+ {
+ ent = resolvable.SEntity;
+ }
+ else if (instance is IClientIntegrationInstance)
+ {
+ ent = resolvable.CEntity;
+ }
+ else
+ {
+ throw new NotSupportedException($"{t.GetType()} is not a valid kind of IIntegrationInstance");
+ }
+
+ validType = false;
+ return ent is not null;
+ }
+
+ if (t is null)
+ {
+ ent = null;
+ validType = false;
+ return false;
+ }
+
+ ent = null;
+ validType = true; // Dunno what this type is!
+ return false;
+ }
+}
diff --git a/Content.IntegrationTests/NUnit/Constraints/LifeStageConstraint.cs b/Content.IntegrationTests/NUnit/Constraints/LifeStageConstraint.cs
new file mode 100644
index 00000000000..0539a6b54ef
--- /dev/null
+++ b/Content.IntegrationTests/NUnit/Constraints/LifeStageConstraint.cs
@@ -0,0 +1,136 @@
+#nullable enable
+using NUnit.Framework.Constraints;
+using Robust.Shared.GameObjects;
+using Robust.UnitTesting;
+
+namespace Content.IntegrationTests.NUnit.Constraints;
+
+///
+/// A constraint for an entity's lifestage.
+///
+///
+public sealed class LifeStageConstraint(EntityLifeStage stage, IIntegrationInstance instance) : Constraint
+{
+ public override ConstraintResult ApplyTo(TActual actual)
+ {
+ if (!ConstraintHelpers.TryActualAsEnt(actual, instance, out var ent, out var error))
+ {
+ if (error)
+ {
+ throw new NotImplementedException(
+ $"The input type {typeof(TActual)} to {nameof(CompExistsConstraint)} is not a supported entity id.");
+ }
+
+ return new ConstraintResult(this, actual, ConstraintStatus.Failure);
+ }
+
+ var lifestage = instance.EntMan.GetComponentOrNull(ent.Value)?.EntityLifeStage;
+
+ return new ConstraintResult(this,
+ lifestage,
+ lifestage == stage || (lifestage is null && stage is EntityLifeStage.Deleted));
+ }
+
+ public override string Description => stage switch
+ {
+ EntityLifeStage.PreInit => "preinitialized",
+ EntityLifeStage.Initializing => "initializing",
+ EntityLifeStage.Initialized => "initialized",
+ EntityLifeStage.MapInitialized => "map initialized",
+ EntityLifeStage.Terminating => "terminating",
+ EntityLifeStage.Deleted => "deleted",
+ _ => throw new ArgumentOutOfRangeException(nameof(stage), stage, null),
+ };
+}
+
+///
+/// Provides constraints for testing if an entity is in the given lifestage.
+///
+///
+///
+/// // Assert that the server sided entity myEntity is MapInitialized.
+/// Assert.That(myEntity, Is.MapInitialized(Server));
+///
+///
+public static class LifeStageConstraintExtensions
+{
+ extension(Is)
+ {
+ public static LifeStageConstraint LifeStage(EntityLifeStage stage, IIntegrationInstance instance)
+ {
+ return new LifeStageConstraint(stage, instance);
+ }
+
+ public static LifeStageConstraint PreInit(IIntegrationInstance instance)
+ {
+ return Is.LifeStage(EntityLifeStage.PreInit, instance);
+ }
+
+ public static LifeStageConstraint Initializing(IIntegrationInstance instance)
+ {
+ return Is.LifeStage(EntityLifeStage.Initializing, instance);
+ }
+
+ public static LifeStageConstraint Initialized(IIntegrationInstance instance)
+ {
+ return Is.LifeStage(EntityLifeStage.Initialized, instance);
+ }
+
+ public static LifeStageConstraint MapInitialized(IIntegrationInstance instance)
+ {
+ return Is.LifeStage(EntityLifeStage.MapInitialized, instance);
+ }
+
+ public static LifeStageConstraint Terminating(IIntegrationInstance instance)
+ {
+ return Is.LifeStage(EntityLifeStage.Terminating, instance);
+ }
+
+ public static LifeStageConstraint Deleted(IIntegrationInstance instance)
+ {
+ return Is.LifeStage(EntityLifeStage.Deleted, instance);
+ }
+ }
+
+ extension(ConstraintExpression expr)
+ {
+ public LifeStageConstraint LifeStage(EntityLifeStage stage, IIntegrationInstance instance)
+ {
+ var c = new LifeStageConstraint(stage, instance);
+
+ expr.Append(c);
+
+ return c;
+ }
+
+ public LifeStageConstraint PreInit(IIntegrationInstance instance)
+ {
+ return expr.LifeStage(EntityLifeStage.PreInit, instance);
+ }
+
+ public LifeStageConstraint Initializing(IIntegrationInstance instance)
+ {
+ return expr.LifeStage(EntityLifeStage.Initializing, instance);
+ }
+
+ public LifeStageConstraint Initialized(IIntegrationInstance instance)
+ {
+ return expr.LifeStage(EntityLifeStage.Initialized, instance);
+ }
+
+ public LifeStageConstraint MapInitialized(IIntegrationInstance instance)
+ {
+ return expr.LifeStage(EntityLifeStage.MapInitialized, instance);
+ }
+
+ public LifeStageConstraint Terminating(IIntegrationInstance instance)
+ {
+ return expr.LifeStage(EntityLifeStage.Terminating, instance);
+ }
+
+ public LifeStageConstraint Deleted(IIntegrationInstance instance)
+ {
+ return expr.LifeStage(EntityLifeStage.Deleted, instance);
+ }
+ }
+}
diff --git a/Content.IntegrationTests/NUnit/Operators/CompOperator.cs b/Content.IntegrationTests/NUnit/Operators/CompOperator.cs
new file mode 100644
index 00000000000..c52bcac37e7
--- /dev/null
+++ b/Content.IntegrationTests/NUnit/Operators/CompOperator.cs
@@ -0,0 +1,31 @@
+using Content.IntegrationTests.NUnit.Constraints;
+using NUnit.Framework.Constraints;
+using Robust.UnitTesting;
+
+namespace Content.IntegrationTests.NUnit.Operators;
+
+///
+/// An operator for use by nunit constraint resolution.
+///
+///
+public sealed class CompOperator : SelfResolvingOperator
+{
+ private readonly Type _tComp;
+ private readonly IIntegrationInstance _instance;
+
+ public CompOperator(Type tComp, IIntegrationInstance instance)
+ {
+ _tComp = tComp;
+ _instance = instance;
+
+ left_precedence = right_precedence = 1;
+ }
+
+ public override void Reduce(ConstraintBuilder.ConstraintStack stack)
+ {
+ if (RightContext is null or BinaryOperator)
+ stack.Push(new CompExistsConstraint(_tComp, _instance));
+ else
+ stack.Push(new CompConstraint(_tComp, _instance, stack.Pop()));
+ }
+}
diff --git a/Content.IntegrationTests/NUnit/Utilities/IResolvesToEntity.cs b/Content.IntegrationTests/NUnit/Utilities/IResolvesToEntity.cs
new file mode 100644
index 00000000000..bf039c597ce
--- /dev/null
+++ b/Content.IntegrationTests/NUnit/Utilities/IResolvesToEntity.cs
@@ -0,0 +1,19 @@
+using Robust.Shared.GameObjects;
+
+namespace Content.IntegrationTests.NUnit.Utilities;
+
+///
+/// An interface for objects that NUnit constraints should treat as a sided entity.
+///
+public interface IResolvesToEntity
+{
+ ///
+ /// The server-sided entity, if any.
+ ///
+ EntityUid? SEntity { get; }
+ ///
+ /// The client-sided entity, if any.
+ ///
+ EntityUid? CEntity { get; }
+}
+
diff --git a/Content.IntegrationTests/NUnit/Utilities/ITestExtensions.cs b/Content.IntegrationTests/NUnit/Utilities/ITestExtensions.cs
new file mode 100644
index 00000000000..878748949fd
--- /dev/null
+++ b/Content.IntegrationTests/NUnit/Utilities/ITestExtensions.cs
@@ -0,0 +1,28 @@
+using Content.IntegrationTests.Fixtures;
+using NUnit.Framework.Interfaces;
+
+namespace Content.IntegrationTests.NUnit.Utilities;
+
+public static class ITestExtensions
+{
+ extension(T test)
+ where T : ITest
+ {
+ ///
+ /// Ensures the given fixture is a , and if not gives a nice error message.
+ ///
+ /// The caller's type, usually an attribute.
+ /// The .
+ /// Thrown when the given test isn't a
+ public void EnsureFixtureIsGameTest(Type callingType, out GameTest gt)
+ {
+ if (test.Fixture is not GameTest gameTest)
+ {
+ throw new NotSupportedException(
+ $"The fixture {test.Fixture?.GetType()} needs to be a GameTest for {callingType.Name} to work.");
+ }
+
+ gt = gameTest;
+ }
+ }
+}
diff --git a/Content.IntegrationTests/Tests/GameTestTests/ContraintTests.cs b/Content.IntegrationTests/Tests/GameTestTests/ContraintTests.cs
new file mode 100644
index 00000000000..34a6d6dd9c0
--- /dev/null
+++ b/Content.IntegrationTests/Tests/GameTestTests/ContraintTests.cs
@@ -0,0 +1,86 @@
+#nullable enable
+using Content.IntegrationTests.Fixtures;
+using Content.IntegrationTests.Fixtures.Attributes;
+using Content.IntegrationTests.NUnit.Constraints;
+using Content.IntegrationTests.NUnit.Operators;
+using Robust.Shared.GameObjects;
+
+namespace Content.IntegrationTests.Tests.GameTestTests;
+
+public sealed class ConstraintsTests : GameTest
+{
+ [Test]
+ [TestOf(typeof(CompExistsConstraint))]
+ [Description("Ensures that a freshly spawned entity matches a constraint stating it has MetaData.")]
+ [RunOnSide(Side.Server)]
+ public void CompPositive()
+ {
+ var ent = SSpawn(null);
+
+ Assert.That(ent, Has.Comp(Server));
+ }
+
+ [Test]
+ [TestOf(typeof(CompOperator))]
+ [Description("Ensures that NUnit property access works on Comp constraints.")]
+ [RunOnSide(Side.Server)]
+ public void CompPropertyAccess()
+ {
+ var ent = SSpawn(null);
+
+ Assert.That(ent,
+ Has
+ .Comp(Server)
+ .Property(nameof(MetaDataComponent.EntityDeleted))
+ .EqualTo(false)
+ );
+ }
+
+ [Test]
+ [TestOf(typeof(CompExistsConstraint))]
+ [Description("Ensures that a freshly spawned entity does not match a constraint stating it has some odd component.")]
+ [RunOnSide(Side.Server)]
+ public void CompNegative()
+ {
+ var ent = SSpawn(null);
+
+ // Arbitrary pick.
+ Assert.That(ent, Has.No.Comp(Server));
+ }
+
+ [Test]
+ [TestOf(typeof(LifeStageConstraint))]
+ [Description("Ensures that a freshly deleted entity is deleted to constraints.")]
+ [RunOnSide(Side.Server)]
+ public void DeletedPositive()
+ {
+ var ent = SSpawn(null);
+
+ SDeleteNow(ent);
+
+ Assert.That(ent, Is.Deleted(Server));
+ }
+
+ [Test]
+ [TestOf(typeof(LifeStageConstraint))]
+ [RunOnSide(Side.Server)]
+ [Description("Entities that never existed are currently considered deleted by constraints.")]
+ public void DeletedNeverExisted()
+ {
+ // We'll never spawn this many ents in tests without it taking all damn day.
+ var ent = new EntityUid(int.MaxValue / 2);
+
+ Assert.That(ent, Is.Deleted(Server), "Entites that never existed still count as deleted.");
+ }
+
+ [Test]
+ [TestOf(typeof(LifeStageConstraint))]
+ [Description("Entities that are alive do not count as deleted.")]
+ [RunOnSide(Side.Server)]
+ public void DeletedNegative()
+ {
+ var ent = SSpawn(null);
+
+ Assert.That(ent, Is.Not.Deleted(Server));
+ }
+}
diff --git a/Content.IntegrationTests/Tests/GameTestTests/DependencyTests.cs b/Content.IntegrationTests/Tests/GameTestTests/DependencyTests.cs
new file mode 100644
index 00000000000..5ce2b486fd5
--- /dev/null
+++ b/Content.IntegrationTests/Tests/GameTestTests/DependencyTests.cs
@@ -0,0 +1,54 @@
+#nullable enable
+using System.Collections.Generic;
+using Content.Client.GameTicking.Managers;
+using Content.IntegrationTests.Fixtures;
+using Content.IntegrationTests.Fixtures.Attributes;
+using Content.IntegrationTests.NUnit.Constraints;
+using Content.Server.GameTicking;
+using Content.Shared.GameTicking;
+using Robust.Shared.GameObjects;
+
+namespace Content.IntegrationTests.Tests.GameTestTests;
+
+[TestOf(typeof(GameTest))]
+[TestOf(typeof(SidedDependencyAttribute))]
+public sealed class DependencyTests : GameTest
+{
+ [SidedDependency(Side.Server)] private readonly SharedGameTicker _sGameTicker = null!;
+ [SidedDependency(Side.Client)] private readonly SharedGameTicker _cGameTicker = null!;
+ [SidedDependency(Side.Server)] private readonly EntityQuery _sXformQuery = default!;
+
+ [Test]
+ [Description("Asserts that sided dependencies actually grab from the right sides.")]
+ public void DependenciesRespectSides()
+ {
+ using (Assert.EnterMultipleScope())
+ {
+ Assert.That(!ReferenceEquals(SEntMan, CEntMan), "Server and client entity managers should be distinct");
+ Assert.That(SEntMan, Is.EqualTo(Server.EntMan).Using