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(ReferenceEqualityComparer.Instance)); + Assert.That(CEntMan, Is.EqualTo(Client.EntMan).Using(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()); + Assert.That(_cGameTicker, Is.TypeOf()); + } + } + + [Test] + [Description("Asserts that query dependencies function")] + public async Task QueryDependencies() + { + var ent = await Spawn(null); + + Assert.That(_sXformQuery.HasComp(ent), Is.True); + } +} diff --git a/Content.IntegrationTests/Tests/GameTestTests/DisconnectedDependencyTest.cs b/Content.IntegrationTests/Tests/GameTestTests/DisconnectedDependencyTest.cs new file mode 100644 index 00000000000..d71c56c3135 --- /dev/null +++ b/Content.IntegrationTests/Tests/GameTestTests/DisconnectedDependencyTest.cs @@ -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(() => + { + 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 + } +} diff --git a/Content.IntegrationTests/Tests/GameTestTests/EnsureCVarTest.cs b/Content.IntegrationTests/Tests/GameTestTests/EnsureCVarTest.cs new file mode 100644 index 00000000000..942254807fb --- /dev/null +++ b/Content.IntegrationTests/Tests/GameTestTests/EnsureCVarTest.cs @@ -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."); + } + } +} diff --git a/Content.IntegrationTests/Tests/GameTestTests/EnsureCVarsTestCVars.cs b/Content.IntegrationTests/Tests/GameTestTests/EnsureCVarsTestCVars.cs new file mode 100644 index 00000000000..3d7afccd57e --- /dev/null +++ b/Content.IntegrationTests/Tests/GameTestTests/EnsureCVarsTestCVars.cs @@ -0,0 +1,14 @@ +#nullable enable +using Robust.Shared.Configuration; + +namespace Content.IntegrationTests.Tests.GameTestTests; + +[CVarDefs] +public sealed class EnsureCVarsTestCVars +{ + public static readonly CVarDef Foo = + CVarDef.Create("tests.ensure_cvars.foo", false, CVar.SERVER); + + public static readonly CVarDef Bar = + CVarDef.Create("tests.ensure_cvars.bar", 3, CVar.SERVER); +} diff --git a/Content.IntegrationTests/Tests/GameTestTests/IGameTestPairConfigModifierAttributeTests.cs b/Content.IntegrationTests/Tests/GameTestTests/IGameTestPairConfigModifierAttributeTests.cs new file mode 100644 index 00000000000..080c143b8a8 --- /dev/null +++ b/Content.IntegrationTests/Tests/GameTestTests/IGameTestPairConfigModifierAttributeTests.cs @@ -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); + } +} diff --git a/Content.IntegrationTests/Tests/GameTestTests/RunOnSideTests.cs b/Content.IntegrationTests/Tests/GameTestTests/RunOnSideTests.cs new file mode 100644 index 00000000000..1a3de265144 --- /dev/null +++ b/Content.IntegrationTests/Tests/GameTestTests/RunOnSideTests.cs @@ -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); + } +} diff --git a/Content.IntegrationTests/Utility/TestProperties.cs b/Content.IntegrationTests/Utility/TestProperties.cs new file mode 100644 index 00000000000..c8ed338a829 --- /dev/null +++ b/Content.IntegrationTests/Utility/TestProperties.cs @@ -0,0 +1,9 @@ +namespace Content.IntegrationTests.Utility; + +public static class TestProperties +{ + /// + /// Name of the property describing what kind of test frame a test is running under. + /// + public const string TestFrameKind = "TestFrameKind"; +}