GameTest part 1 (#43182)

* Ports much of GameTest, minus all the WIP stuff.

* remark.

* EnsureCVar now adds test properties.

* Some cleanup and functionality.

* Ignore broken test. Needs fixed eventually. Also explicit context config.

* TrackingIssue attribute.

* oops

* Pair config attribute.

* Remove SystemAttribute in favor of using the EntitySystemManager dependency collection.

* Ensure idleness.

* More tests for tests.

* More specific failure catching tests.

* Reverse attribute resolution order so suite-wide attributes happen first.

* Get rid of AffectedProperties again because I need to refactor PoolSettings for that.

* Poke

* Final cleanup pass.

* Update Content.IntegrationTests/Fixtures/Attributes/RunOnSideAttribute.cs

Co-authored-by: Tayrtahn <tayrtahn@gmail.com>

* Update Content.IntegrationTests/Fixtures/Attributes/Side.cs

Co-authored-by: Tayrtahn <tayrtahn@gmail.com>

* Update Content.IntegrationTests/NUnit/Utilities/ITestExtensions.cs

Co-authored-by: Tayrtahn <tayrtahn@gmail.com>

* Fixes.

* shut up nunit.

* Make TrackingIssue a bit strict on purpose, so people don't put junk here.

* Update Content.IntegrationTests/Tests/GameTestTests/DisconnectedDependencyTest.cs

Co-authored-by: Tayrtahn <tayrtahn@gmail.com>

* Address.

---------

Co-authored-by: Tayrtahn <tayrtahn@gmail.com>
This commit is contained in:
Moony
2026-03-27 12:08:47 -07:00
committed by GitHub
parent bc29aeac3b
commit 9adb10d791
29 changed files with 1697 additions and 0 deletions
@@ -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;
/// <summary>
/// Ensures the given CVar, on the given side (or both), is the given value.
/// Attribute version of <see cref="GameTest.OverrideCVar{T}"/>, and stores the old value the same way.
/// </summary>
/// <remarks>This only works with <see cref="GameTest"/> fixtures.</remarks>
/// <param name="side">The side to set the CVar on, or both.</param>
/// <param name="definitionType">The type the CVar is defined on.</param>
/// <param name="fieldName">The name of the static field defining the CVar.</param>
/// <param name="value">The value to set the CVar to.</param>
/// <example>
/// <code>
/// [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.
/// }
/// </code>
/// </example>
/// <seealso cref="GameTest"/>
[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}");
}
}
@@ -0,0 +1,20 @@
namespace Content.IntegrationTests.Fixtures.Attributes;
/// <summary>
/// Marks an attribute as a modifier for <see cref="GameTest"/> fixtures.
/// These attributes can be applied to both test methods and fixtures.
/// </summary>
/// <remarks>
/// GameTest modifiers are <b>encouraged</b> to also implement IApplyToTest and add properties to the test
/// indicating their presence.
/// </remarks>
public interface IGameTestModifier
{
/// <summary>
/// Method called by GameTest on itself when applying <see cref="GameTest"/> modifiers.
/// </summary>
/// <param name="test">The test being modified</param>
/// <returns>Async task to await.</returns>
Task ApplyToTest(GameTest test);
}
@@ -0,0 +1,25 @@
#nullable enable
namespace Content.IntegrationTests.Fixtures.Attributes;
/// <summary>
/// Interface used for <see cref="GameTest"/> 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.
/// </summary>
public interface IGameTestPairConfigModifier
{
/// <summary>
/// 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.
/// </summary>
bool Exclusive { get; }
/// <summary>
/// Called when GameTest needs its <see cref="PoolSettings"/> modified by the modifier.
/// </summary>
/// <param name="test">The test we're applying to.</param>
/// <param name="settings">The settings object to modify.</param>
void ApplyToPairSettings(GameTest test, ref PoolSettings settings);
}
@@ -0,0 +1,53 @@
#nullable enable
using System.Reflection;
namespace Content.IntegrationTests.Fixtures.Attributes;
/// <summary>
/// Configures the test pair using settings from the given type (by default the current test) and static property member.
/// </summary>
/// <param name="sourceType">The type to look up the member on, if any.</param>
/// <param name="sourceMember">The static property to read the settings from.</param>
[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)!;
}
}
@@ -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;
/// <summary>
/// Ensures a test method runs on the given side (client or server, not neither nor both).
/// </summary>
/// <remarks>
/// This only works for <see cref="GameTest"/> fixtures.
/// </remarks>
/// <seealso cref="GameTest"/>
[AttributeUsage(AttributeTargets.Method)]
public sealed class RunOnSideAttribute : Attribute, IWrapTestMethod, IImplyFixture, IApplyToTest
{
public const string RunOnSideProperty = "RanOnSide";
/// <summary>
/// Which side to run the inner test code on, if not the test thread.
/// </summary>
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());
}
}
@@ -0,0 +1,27 @@
#nullable enable
namespace Content.IntegrationTests.Fixtures.Attributes;
/// <summary>
/// A flag enum representing a side of a testpair.
/// </summary>
[Flags]
public enum Side : byte
{
/// <summary>
/// Bitflag representing the client side of a testpair.
/// </summary>
Client = 1,
/// <summary>
/// Bitflag representing the server side of a testpair.
/// </summary>
Server = 2,
/// <summary>
/// A value indicating no side was specified. You shouldn't use this outside of checking for it as an error.
/// </summary>
Neither = 0,
/// <summary>
/// A value indicating both sides were specified.
/// </summary>
Both = Client | Server,
}
@@ -0,0 +1,23 @@
#nullable enable
namespace Content.IntegrationTests.Fixtures.Attributes;
/// <summary>
/// Marks a field on a <see cref="GameTest"/> fixture as needing to be populated with an IoC dependency from the given side.
/// </summary>
/// <seealso cref="GameTest"/>
[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; }
}
@@ -0,0 +1,53 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
namespace Content.IntegrationTests.Fixtures.Attributes;
/// <summary>
/// <para>
/// An attribute meant to attach an issue (usually related to the test) to a given test or test fixture.
/// This sets the <c>TrackingIssue</c> property on the test, and helps developers find why a test exists or why it
/// is broken.
/// </para>
/// <para>
/// 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.
/// </para>
/// <para>
/// 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.
/// </para>
/// </summary>
public sealed class TrackingIssueAttribute : PropertyAttribute
{
/// <summary>
/// Domains we allow for tracking issues, to avoid people putting discord or discourse links.
/// </summary>
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}");
}
}
}
@@ -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<string, object> _clientCVarOverrides = new();
private readonly Dictionary<string, object> _serverCVarOverrides = new();
/// <summary>
/// Adds a setup-time override for a given cvar, for use by <see cref="IGameTestModifier"/>s.
/// </summary>
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();
}
/// <summary>
/// Sets a given CVar for the provided side.
/// </summary>
/// <remarks>Does its own cleanup, you do not need to set the CVar back yourself.</remarks>
public async Task OverrideCVar<T>(Side side, CVarDef<T> 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();
}
}
@@ -0,0 +1,12 @@
namespace Content.IntegrationTests.Fixtures;
public abstract partial class GameTest
{
/// <summary>
/// All-default-settings PoolSettings, with the client and server disconnected.
/// </summary>
protected static PoolSettings PsDisconnected => new()
{
Connected = false,
};
}
@@ -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
{
/// <summary>
/// Contains all server entities spawned using GameTest proxy methods.
/// </summary>
private readonly List<EntityUid> _serverEntitiesToClean = new();
/// <summary>
/// Contains all client entities spawned using GameTest proxy methods.
/// </summary>
private readonly List<EntityUid> _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);
}
})
);
}
/// <summary>
/// Returns a string representation of an entity for the server.
/// </summary>
public string SToPrettyString(EntityUid uid)
{
return Pair.Server.EntMan.ToPrettyString(uid);
}
/// <summary>
/// Returns a string representation of an entity for the client.
/// </summary>
public string CToPrettyString(EntityUid uid)
{
return Pair.Client.EntMan.ToPrettyString(uid);
}
/// <summary>
/// Converts a server EntityUid into the client-side equivalent entity.
/// </summary>
public EntityUid ToClientUid(EntityUid serverUid)
{
return Pair.ToClientUid(serverUid);
}
/// <summary>
/// Converts a client EntityUid into the server-side equivalent entity.
/// </summary>
public EntityUid ToServerUid(EntityUid clientUid)
{
return Pair.ToServerUid(clientUid);
}
/// <summary>
/// Retrieves the given component from an entity on the server.
/// </summary>
public T SComp<T>(EntityUid target)
where T : IComponent
{
return SEntMan.GetComponent<T>(target);
}
/// <summary>
/// Attempts to retrieve the given component from an entity on the server.
/// </summary>
public bool STryComp<T>(EntityUid? target, [NotNullWhen(true)] out T? component)
where T : IComponent
{
return SEntMan.TryGetComponent(target, out component);
}
/// <summary>
/// Retrieves the given component from an entity on the client.
/// </summary>
public T CComp<T>(EntityUid target)
where T : IComponent
{
return CEntMan.GetComponent<T>(target);
}
/// <summary>
/// Attempts to retrieve the given component from an entity on the server.
/// </summary>
public bool CTryComp<T>(EntityUid? target, [NotNullWhen(true)] out T? component)
where T : IComponent
{
return SEntMan.TryGetComponent(target, out component);
}
/// <summary>
/// Pairs an EntityUid with the given component, from the server.
/// </summary>
public Entity<T> SEntity<T>(EntityUid target)
where T : IComponent
{
return new(target, SEntMan.GetComponent<T>(target));
}
/// <summary>
/// Pairs an EntityUid with the given component, from the client.
/// </summary>
public Entity<T> CEntity<T>(EntityUid target)
where T : IComponent
{
return new(target, CEntMan.GetComponent<T>(target));
}
/// <summary>
/// Spawns an entity on the server.
/// </summary>
/// <remarks>This tracks the entity for post-test cleanup.</remarks>
public EntityUid SSpawn(string? id)
{
var res = SEntMan.Spawn(id);
_serverEntitiesToClean.Add(res);
return res;
}
/// <summary>
/// Spawns an entity on the server at a location.
/// </summary>
/// <remarks>This tracks the entity for post-test cleanup.</remarks>
public EntityUid SSpawnAtPosition(string? id, EntityCoordinates coordinates)
{
var res = SEntMan.SpawnAtPosition(id, coordinates);
_serverEntitiesToClean.Add(res);
return res;
}
/// <summary>
/// Spawns an entity on the client.
/// </summary>
/// <remarks>This tracks the entity for post-test cleanup.</remarks>
public EntityUid CSpawn(string? id)
{
var res = CEntMan.Spawn(id);
_clientEntitiesToClean.Add(res);
return res;
}
/// <summary>
/// Spawns an entity on the server at a location.
/// </summary>
/// <remarks>This tracks the entity for post-test cleanup.</remarks>
public EntityUid CSpawnAtPosition(string? id, EntityCoordinates coordinates)
{
var res = CEntMan.SpawnAtPosition(id, coordinates);
_clientEntitiesToClean.Add(res);
return res;
}
/// <summary>
/// Asynchronously spawns an entity on the server.
/// </summary>
public async Task<EntityUid> Spawn(string? id)
{
var ent = EntityUid.Invalid;
await Server.WaitPost(() => ent = SSpawn(id));
return ent;
}
/// <summary>
/// Asynchronously spawns an entity on the server at the given position.
/// </summary>
public async Task<EntityUid> SpawnAtPosition(string? id, EntityCoordinates coords)
{
var ent = EntityUid.Invalid;
await Server.WaitPost(() => ent = SSpawnAtPosition(id, coords));
return ent;
}
/// <summary>
/// Deletes an entity on the server immediately.
/// </summary>
public void SDeleteNow(EntityUid id)
{
SEntMan.DeleteEntity(id);
}
/// <summary>
/// Deletes an entity on the client immediately.
/// </summary>
public void CDeleteNow(EntityUid id)
{
CEntMan.DeleteEntity(id);
}
/// <summary>
/// Queues an entity for deletion at the end of the tick on the server.
/// </summary>
public void SQueueDel(EntityUid id)
{
SEntMan.QueueDeleteEntity(id);
}
/// <summary>
/// Queues an entity for deletion at the end of the tick on the client.
/// </summary>
public void CQueueDel(EntityUid id)
{
CEntMan.QueueDeleteEntity(id);
}
}
@@ -0,0 +1,36 @@
namespace Content.IntegrationTests.Fixtures;
public abstract partial class GameTest
{
/// <summary>
/// Runs the client and server for the given number of ticks, in lockstep.
/// </summary>
/// <remarks>
/// Do not use this as a barrier for client-server synchronization, use <see cref="RunUntilSynced"/>.
/// </remarks>
public Task RunTicksSync(int ticks)
{
return Pair.RunTicksSync(ticks);
}
/// <summary>
/// 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.
/// </summary>
public async Task RunUntilSynced()
{
await Pair.RunUntilSynced();
}
/// <summary>
/// Runs the test pair for a number of (simulated) seconds.
/// </summary>
/// <remarks>
/// 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 <see cref="RunUntilSynced"/>.
/// </remarks>
public Task RunSeconds(float seconds)
{
return Pair.RunSeconds(seconds);
}
}
@@ -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;
/// <summary>
/// <para>
/// A test fixture with an integrated <see cref="GameTest.Pair">test pair</see>,
/// proxy methods for efficient test writing, utilities for ensuring tests clean up correctly,
/// and dependency injection (<see cref="SidedDependencyAttribute"/>).
/// </para>
/// <para>
/// Tests using GameTest support some additional class and method level attributes, namely
/// <see cref="RunOnSideAttribute"/>.
/// Attributes can be used to control how the test runs.
/// </para>
/// </summary>
/// <seealso cref="CompConstraintExtensions"/>
/// <seealso cref="LifeStageConstraintExtensions"/>
[TestFixture]
[FixtureLifeCycle(LifeCycle.InstancePerTestCase)]
[Property(TestProperties.TestFrameKind, nameof(GameTest))]
[SuppressMessage("Structure", "NUnit1028:The non-test method is public")]
public abstract partial class GameTest
{
/// <summary>
/// Set if the test manually marks itself dirty.
/// </summary>
private bool _pairDestroyed;
/// <summary>
/// Tests-testing-tests assistant to run right before the pair is returned.
/// </summary>
public event Action? PreFinalizeHook;
/// <summary>
/// The main thread of the game server.
/// </summary>
public Thread ServerThread { get; private set; } = null!; // NULLABILITY: This is always set during test setup.
/// <summary>
/// The main thread of the game client.
/// </summary>
public Thread ClientThread { get; private set; } = null!; // NULLABILITY: This is always set during test setup.
/// <summary>
/// Settings for the client/server pair.
/// By default, this gets you a client and server that have connected together.
/// </summary>
/// <remarks>
/// Always return a new instance whenever this is read. In other words, no backing field please. Arrow syntax only.
/// </remarks>
public virtual PoolSettings PoolSettings => new() { Connected = true };
/// <summary>
/// The client and server pair.
/// </summary>
public TestPair Pair { get; private set; } = default!; // NULLABILITY: This is always set during test setup.
/// <summary>
/// The game server instance.
/// </summary>
public RobustIntegrationTest.ServerIntegrationInstance Server => Pair.Server;
/// <summary>
/// The game client instance.
/// </summary>
public RobustIntegrationTest.ClientIntegrationInstance Client => Pair.Client;
/// <summary>
/// The test player's server session, if any.
/// </summary>
public ICommonSession? ServerSession => Pair.Player;
/// <summary>
/// The server-side entity manager.
/// </summary>
[SidedDependency(Side.Server)]
public IEntityManager SEntMan = null!;
/// <summary>
/// The client-side entity manager.
/// </summary>
[SidedDependency(Side.Client)]
public IEntityManager CEntMan = null!;
/// <summary>
/// The server-side prototype manager.
/// </summary>
[SidedDependency(Side.Server)]
public IPrototypeManager SProtoMan = null!;
/// <summary>
/// The client-side prototype manager.
/// </summary>
[SidedDependency(Side.Client)]
public IPrototypeManager CProtoMan = null!;
/// <summary>
/// The server-side game-timing manager.
/// </summary>
[SidedDependency(Side.Server)]
public IGameTiming SGameTiming = null!;
/// <summary>
/// The client-side game-timing manager.
/// </summary>
[SidedDependency(Side.Client)]
public IClientGameTiming CGameTiming = null!;
/// <summary>
/// The test map we're using, if any.
/// </summary>
public TestMapData? TestMap => Pair.TestMap;
private bool _setupDone = false;
/// <summary>
/// Primary setup task for the fixture.
/// Custom setup must run after this.
/// </summary>
[SetUp]
public virtual async Task DoSetup()
{
_pairDestroyed = false;
var testContext = TestContext.CurrentContext;
var test = testContext.Test;
var settings = PoolSettings;
var pairAttribs = test.Method!.GetCustomAttributes<IGameTestPairConfigModifier>(false);
var pairSuiteAttribs = test.Method!.TypeInfo.GetCustomAttributes<IGameTestPairConfigModifier>(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<IGameTestModifier>(false);
var suiteAttribs = test.Method!.TypeInfo.GetCustomAttributes<IGameTestModifier>(true);
foreach (var attribute in suiteAttribs.Concat(attribs))
{
await attribute.ApplyToTest(this);
}
_setupDone = true;
await DoPreTestOverrides();
await Pair.RunUntilSynced();
}
/// <summary>
/// Injects <see cref="SidedDependencyAttribute"/> dependencies into the target object.
/// </summary>
/// <remarks>
/// This is called on the GameTest itself automatically. Don't call it twice on the same object.
/// </remarks>
/// <param name="target">The object to inject into.</param>
public void InjectDependencies(object target)
{
foreach (var field in target.GetType().GetAllFields())
{
if (field.GetCustomAttribute<SidedDependencyAttribute>() 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));
}
}
}
}
/// <summary>
/// Primary teardown task for the fixture.
/// Custom teardown must run before this.
/// </summary>
[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();
}
}
}
@@ -0,0 +1,33 @@
using NUnit.Framework.Constraints;
using NUnit.Framework.Internal;
using Robust.UnitTesting;
namespace Content.IntegrationTests.NUnit.Constraints;
/// <summary>
/// A prefix constraint like <see cref="PropertyConstraint"/>, for entity components.
/// </summary>
/// <seealso cref="CompConstraintExtensions"/>
public sealed class CompConstraint(Type tComp, IIntegrationInstance instance, IConstraint baseConstraint)
: PrefixConstraint(baseConstraint, $"component {tComp.Name}")
{
public override ConstraintResult ApplyTo<TActual>(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);
}
}
@@ -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;
/// <summary>
/// Provides <see cref="M:Content.IntegrationTests.NUnit.Constraints.CompConstraintExtensions.extension(NUnit.Framework.Has).Comp``1(Robust.UnitTesting.IIntegrationInstance)">Has.Comp&lt;T&gt;(side)</see>,
/// a constraint that allows you to check for the presence of, or operate on, a component.
/// </summary>
/// <example>
/// <code>
/// // Assert that the server sided entity myEntity has ItemComponent on the server.
/// Assert.That(myEntity, Has.Comp&lt;ItemComponent&gt;(Server));
/// </code>
/// </example>
public static class CompConstraintExtensions
{
extension(Has)
{
public static ResolvableConstraintExpression Comp<T>(IIntegrationInstance instance)
where T : IComponent
{
return new ConstraintExpression().Comp<T>(instance);
}
public static ResolvableConstraintExpression Comp(Type t, IIntegrationInstance instance)
{
return new ConstraintExpression().Comp(t, instance);
}
}
extension(ConstraintExpression expr)
{
public ResolvableConstraintExpression Comp<T>(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));
}
}
}
@@ -0,0 +1,30 @@
#nullable enable
using NUnit.Framework.Constraints;
using Robust.UnitTesting;
namespace Content.IntegrationTests.NUnit.Constraints;
/// <summary>
/// Constraint for whether a component exists.
/// </summary>
/// <seealso cref="CompConstraintExtensions"/>
public sealed class CompExistsConstraint(Type component, IIntegrationInstance instance) : Constraint
{
public override ConstraintResult ApplyTo<TActual>(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}";
}
@@ -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
{
/// <summary>
/// A constraint implementation helper to convert TActual into an entityuid.
/// </summary>
/// <param name="t">The input value to try to get an entity uid from.</param>
/// <param name="instance">The integration test instance to resolve the entity from.</param>
/// <param name="ent">The resulting entity uid.</param>
/// <param name="validType">Whether TActual is recognized to begin with.</param>
/// <typeparam name="TActual">The type to cast out of.</typeparam>
public static bool TryActualAsEnt<TActual>(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<EntityUid> 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;
}
}
@@ -0,0 +1,136 @@
#nullable enable
using NUnit.Framework.Constraints;
using Robust.Shared.GameObjects;
using Robust.UnitTesting;
namespace Content.IntegrationTests.NUnit.Constraints;
/// <summary>
/// A constraint for an entity's lifestage.
/// </summary>
/// <seealso cref="LifeStageConstraintExtensions"/>
public sealed class LifeStageConstraint(EntityLifeStage stage, IIntegrationInstance instance) : Constraint
{
public override ConstraintResult ApplyTo<TActual>(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<MetaDataComponent>(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),
};
}
/// <summary>
/// Provides constraints for testing if an entity is in the given lifestage.
/// </summary>
/// <example>
/// <code>
/// // Assert that the server sided entity myEntity is MapInitialized.
/// Assert.That(myEntity, Is.MapInitialized(Server));
/// </code>
/// </example>
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);
}
}
}
@@ -0,0 +1,31 @@
using Content.IntegrationTests.NUnit.Constraints;
using NUnit.Framework.Constraints;
using Robust.UnitTesting;
namespace Content.IntegrationTests.NUnit.Operators;
/// <summary>
/// An operator for use by nunit constraint resolution.
/// </summary>
/// <seealso cref="CompExistsConstraint"/>
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()));
}
}
@@ -0,0 +1,19 @@
using Robust.Shared.GameObjects;
namespace Content.IntegrationTests.NUnit.Utilities;
/// <summary>
/// An interface for objects that NUnit constraints should treat as a sided entity.
/// </summary>
public interface IResolvesToEntity
{
/// <summary>
/// The server-sided entity, if any.
/// </summary>
EntityUid? SEntity { get; }
/// <summary>
/// The client-sided entity, if any.
/// </summary>
EntityUid? CEntity { get; }
}
@@ -0,0 +1,28 @@
using Content.IntegrationTests.Fixtures;
using NUnit.Framework.Interfaces;
namespace Content.IntegrationTests.NUnit.Utilities;
public static class ITestExtensions
{
extension<T>(T test)
where T : ITest
{
/// <summary>
/// Ensures the given fixture is a <see cref="GameTest"/>, and if not gives a nice error message.
/// </summary>
/// <param name="callingType">The caller's type, usually an attribute.</param>
/// <param name="gt">The <see cref="GameTest"/>.</param>
/// <exception cref="NotSupportedException">Thrown when the given test isn't a <see cref="GameTest"/></exception>
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;
}
}
}
@@ -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<MetaDataComponent>(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<MetaDataComponent>(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<EyeComponent>(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));
}
}
@@ -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<TransformComponent> _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<object?>(ReferenceEqualityComparer.Instance));
Assert.That(CEntMan, Is.EqualTo(Client.EntMan).Using<object?>(ReferenceEqualityComparer.Instance));
}
}
[Test]
[Description("Asserts that system dependencies actually grab from the right sides.")]
public void SystemDependenciesRespectSides()
{
using (Assert.EnterMultipleScope())
{
Assert.That(!ReferenceEquals(_sGameTicker, _cGameTicker),
"Server and client gametickers should be distinct");
Assert.That(_sGameTicker, Is.TypeOf<GameTicker>());
Assert.That(_cGameTicker, Is.TypeOf<ClientGameTicker>());
}
}
[Test]
[Description("Asserts that query dependencies function")]
public async Task QueryDependencies()
{
var ent = await Spawn(null);
Assert.That(_sXformQuery.HasComp(ent), Is.True);
}
}
@@ -0,0 +1,46 @@
#nullable enable
using Content.IntegrationTests.Fixtures;
using Content.IntegrationTests.Fixtures.Attributes;
using Robust.Client.GameObjects;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC.Exceptions;
namespace Content.IntegrationTests.Tests.GameTestTests;
[TestOf(typeof(GameTest))]
[TestOf(typeof(SidedDependencyAttribute))]
public sealed class DisconnectedDependencyTest : GameTest
{
[Test]
[PairConfig(nameof(PsDisconnected))]
[Description($"""
Ensures that GameTest can be started up even when the client {nameof(EntitySystemManager)} isn't initialized yet.
No body is necessary, if this fails then the bug is firmly in GameTest itself.
""")]
public void EnsureGameTestSetupWorksDisconnected()
{
// Nothin'
}
[Test]
[PairConfig(nameof(PsDisconnected))]
[Description("""
Ensures dependency injection that relies on client side systems fails as expected when the client is detached.
""")]
public void ClientSystemDependencyFails()
{
var creature = new Creature();
Assert.Throws<UnregisteredTypeException>(() =>
{
InjectDependencies(creature);
});
}
private sealed class Creature
{
#pragma warning disable CS0414 // Field is assigned but its value is never used
[SidedDependency(Side.Client)] private readonly MapSystem _mapSys = null!;
#pragma warning restore CS0414 // Field is assigned but its value is never used
}
}
@@ -0,0 +1,53 @@
#nullable enable
using System.Linq;
using Content.IntegrationTests.Fixtures;
using Content.IntegrationTests.Fixtures.Attributes;
using Robust.Shared.Configuration;
namespace Content.IntegrationTests.Tests.GameTestTests;
[TestOf(typeof(GameTest))]
[TestOf(typeof(EnsureCVarAttribute))]
[Description("Ensures EnsureCVar actually sets CVars as expected.")]
[EnsureCVar(Side.Server, typeof(EnsureCVarsTestCVars), nameof(EnsureCVarsTestCVars.Foo), true)]
public sealed class EnsureCVarTest : GameTest
{
[SidedDependency(Side.Server)] private readonly IConfigurationManager _sCfg = default!;
[SidedDependency(Side.Client)] private readonly IConfigurationManager _cCfg = default!;
[Test]
[Description("Ensure Foo is set and Bar is not.")]
public void FooIsSet()
{
using (Assert.EnterMultipleScope())
{
Assert.That(_sCfg.GetCVar(EnsureCVarsTestCVars.Foo), Is.True);
Assert.That(_cCfg.GetCVar(EnsureCVarsTestCVars.Foo),
Is.EqualTo(EnsureCVarsTestCVars.Foo.DefaultValue),
"Foo is not replicated and should not be set on the client.");
Assert.That(_sCfg.GetCVar(EnsureCVarsTestCVars.Bar),
Is.EqualTo(EnsureCVarsTestCVars.Bar.DefaultValue));
}
}
[Test]
[EnsureCVar(Side.Server, typeof(EnsureCVarsTestCVars), nameof(EnsureCVarsTestCVars.Bar), 42)]
[Description("Ensure Foo and Bar are set.")]
public void BarIsSet()
{
var props = TestContext.CurrentContext.Test.Properties;
using (Assert.EnterMultipleScope())
{
Assert.That(_sCfg.GetCVar(EnsureCVarsTestCVars.Bar),
Is.EqualTo(42));
Assert.That(_cCfg.GetCVar(EnsureCVarsTestCVars.Bar),
Is.EqualTo(EnsureCVarsTestCVars.Bar.DefaultValue),
"Bar is not replicated and should not be set on the client.");
Assert.That(props[EnsureCVarAttribute.ServerEnsuredCVarsProperty].Count(),
Is.EqualTo(1),
"Expected EnsureCVar to appropriately mark its target test.");
}
}
}
@@ -0,0 +1,14 @@
#nullable enable
using Robust.Shared.Configuration;
namespace Content.IntegrationTests.Tests.GameTestTests;
[CVarDefs]
public sealed class EnsureCVarsTestCVars
{
public static readonly CVarDef<bool> Foo =
CVarDef.Create("tests.ensure_cvars.foo", false, CVar.SERVER);
public static readonly CVarDef<int> Bar =
CVarDef.Create("tests.ensure_cvars.bar", 3, CVar.SERVER);
}
@@ -0,0 +1,23 @@
using Content.IntegrationTests.Fixtures;
using Content.IntegrationTests.Fixtures.Attributes;
namespace Content.IntegrationTests.Tests.GameTestTests;
[TestOf(typeof(GameTest))]
[TestOf(typeof(IGameTestPairConfigModifier))]
public sealed class IGameTestPairConfigModifierAttributeTests : GameTest
{
[Test]
public void Control()
{
Assert.That(Pair.Settings.Connected, Is.True);
}
[Test]
[PairConfig(nameof(PsDisconnected))]
[Description("Ensures pair settings apply.")]
public void PairConfigWorks()
{
Assert.That(Pair.Settings.Connected, Is.False);
}
}
@@ -0,0 +1,43 @@
#nullable enable
using System.Threading;
using Content.IntegrationTests.Fixtures;
using Content.IntegrationTests.Fixtures.Attributes;
namespace Content.IntegrationTests.Tests.GameTestTests;
[TestOf(typeof(GameTest))]
[TestOf(typeof(RunOnSideAttribute))]
[Description("Asserts that RunOnSide actually does as expected and runs the test on the given side.")]
public sealed class RunOnSideTests : GameTest
{
[Test]
[Description("Ensures that the default scenario is the test thread.")]
public void Control()
{
Assert.That(Thread.CurrentThread, Is.Not.EqualTo(ServerThread).And.Not.EqualTo(ClientThread));
}
[Test]
[RunOnSide(Side.Server)]
public void TestServerSide()
{
Assert.That(Thread.CurrentThread, Is.EqualTo(ServerThread));
}
[Test]
[RunOnSide(Side.Client)]
public void TestClientSide()
{
Assert.That(Thread.CurrentThread, Is.EqualTo(ClientThread));
}
[Test]
[RunOnSide(Side.Server)]
[Description("Ensures that RunOnSide appropriately adds a property.")]
[Ignore("TestContext on the game threads is broken.")]
[TrackingIssue("https://github.com/space-wizards/RobustToolbox/issues/6449")]
public void TestProperty()
{
Assert.That(TestContext.CurrentContext.Test.Properties.Get(RunOnSideAttribute.RunOnSideProperty), Is.Not.Null);
}
}
@@ -0,0 +1,9 @@
namespace Content.IntegrationTests.Utility;
public static class TestProperties
{
/// <summary>
/// Name of the property describing what kind of test frame a test is running under.
/// </summary>
public const string TestFrameKind = "TestFrameKind";
}