]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Add generic event listener for integration tests (#40367)
authorLeon Friedrich <60421075+ElectroJr@users.noreply.github.com>
Thu, 9 Oct 2025 13:03:44 +0000 (02:03 +1300)
committerGitHub <noreply@github.com>
Thu, 9 Oct 2025 13:03:44 +0000 (13:03 +0000)
* Add generic event listener for integration tests

* cleanup

* assert that the entity has the component

* comments & new overload

Content.IntegrationTests/Tests/Helpers/TestListenerComponent.cs [new file with mode: 0644]
Content.IntegrationTests/Tests/Helpers/TestListenerSystem.cs [new file with mode: 0644]
Content.IntegrationTests/Tests/Interaction/InteractionTest.Helpers.cs
Content.IntegrationTests/Tests/Movement/SlippingTest.cs

diff --git a/Content.IntegrationTests/Tests/Helpers/TestListenerComponent.cs b/Content.IntegrationTests/Tests/Helpers/TestListenerComponent.cs
new file mode 100644 (file)
index 0000000..817558b
--- /dev/null
@@ -0,0 +1,13 @@
+using System.Collections.Generic;
+using Robust.Shared.GameObjects;
+
+namespace Content.IntegrationTests.Tests.Helpers;
+
+/// <summary>
+/// Component that is used by <see cref="TestListenerSystem{TEvent}"/> to store any information about received events.
+/// </summary>
+[RegisterComponent]
+public sealed partial class TestListenerComponent : Component
+{
+    public Dictionary<Type, List<object>> Events = new();
+}
diff --git a/Content.IntegrationTests/Tests/Helpers/TestListenerSystem.cs b/Content.IntegrationTests/Tests/Helpers/TestListenerSystem.cs
new file mode 100644 (file)
index 0000000..2481cef
--- /dev/null
@@ -0,0 +1,45 @@
+#nullable enable
+using System.Collections.Generic;
+using System.Linq;
+using Robust.Shared.GameObjects;
+using Robust.Shared.Utility;
+
+namespace Content.IntegrationTests.Tests.Helpers;
+
+/// <summary>
+/// Generic system that listens for and records any received events of a given type.
+/// </summary>
+public abstract class TestListenerSystem<TEvent> : EntitySystem where TEvent : notnull
+{
+    public override void Initialize()
+    {
+        // TODO
+        // supporting broadcast events requires cleanup on test finish, which will probably require  changes to the
+        // test pair/pool manager and would conflict with #36797
+        SubscribeLocalEvent<TestListenerComponent, TEvent>(OnDirectedEvent);
+    }
+
+    protected virtual void OnDirectedEvent(Entity<TestListenerComponent> ent, ref TEvent args)
+    {
+        ent.Comp.Events.GetOrNew(args.GetType()).Add(args);
+    }
+
+    public int Count(EntityUid uid, Func<TEvent, bool>? predicate = null)
+    {
+        return GetEvents(uid, predicate).Count();
+    }
+
+    public void Clear(EntityUid uid)
+    {
+        CompOrNull<TestListenerComponent>(uid)?.Events.Remove(typeof(TEvent));
+    }
+
+    public IEnumerable<TEvent> GetEvents(EntityUid uid, Func<TEvent, bool>? predicate = null)
+    {
+        var events = CompOrNull<TestListenerComponent>(uid)?.Events.GetValueOrDefault(typeof(TEvent));
+        if (events == null)
+            return [];
+
+        return events.Cast<TEvent>().Where(e => predicate?.Invoke(e) ?? true);
+    }
+}
index fa16730dd5a4e27f3d6ecdfca37bf715f788282c..d04ed4cb3c72213e32e3cfa874a59f54a0028bcd 100644 (file)
@@ -5,6 +5,7 @@ using System.Linq;
 using System.Numerics;
 using System.Reflection;
 using Content.Client.Construction;
+using Content.IntegrationTests.Tests.Helpers;
 using Content.Server.Atmos.EntitySystems;
 using Content.Server.Construction.Components;
 using Content.Server.Gravity;
@@ -22,6 +23,8 @@ using Robust.Shared.Input;
 using Robust.Shared.Map;
 using Robust.Shared.Map.Components;
 using Robust.Shared.Maths;
+using Robust.Shared.Reflection;
+using Robust.UnitTesting;
 using ItemToggleComponent = Content.Shared.Item.ItemToggle.Components.ItemToggleComponent;
 
 namespace Content.IntegrationTests.Tests.Interaction;
@@ -29,6 +32,8 @@ namespace Content.IntegrationTests.Tests.Interaction;
 // This partial class defines various methods that are useful for performing & validating interactions
 public abstract partial class InteractionTest
 {
+    private Dictionary<Type, EntitySystem> _listenerCache = new();
+
     /// <summary>
     /// Begin constructing an entity.
     /// </summary>
@@ -758,6 +763,139 @@ public abstract partial class InteractionTest
 
     #endregion
 
+    #region EventListener
+
+    /// <summary>
+    /// Asserts that running the given action causes an event to be fired directed at the specified entity (defaults to <see cref="Target"/>).
+    /// </summary>
+    /// <remarks>
+    /// This currently only checks server-side events.
+    /// </remarks>
+    /// <param name="uid">The entity at which the events are supposed to be directed</param>
+    /// <param name="count">How many new events are expected</param>
+    /// <param name="clear">Whether to clear all previously recorded events before invoking the delegate</param>
+    protected async Task AssertFiresEvent<TEvent>(Func<Task> act, EntityUid? uid = null, int count = 1, bool clear = true)
+        where TEvent : notnull
+    {
+        var sys = GetListenerSystem<TEvent>();
+
+        uid ??= STarget;
+        if (uid == null)
+        {
+            Assert.Fail("No target specified");
+            return;
+        }
+
+        if (clear)
+            sys.Clear(uid.Value);
+        else
+            count += sys.Count(uid.Value);
+
+        await Server.WaitPost(() => SEntMan.EnsureComponent<TestListenerComponent>(uid.Value));
+        await act();
+        AssertEvent<TEvent>(uid, count: count);
+    }
+
+    /// <summary>
+    /// This is a variant of <see cref="AssertFiresEvent{TEvent}"/> that passes the delegate to <see cref="RobustIntegrationTest.ServerIntegrationInstance.WaitPost"/>.
+    /// </summary>
+    /// <remarks>
+    /// This currently only checks for server-side events.
+    /// </remarks>
+    /// <param name="uid">The entity at which the events are supposed to be directed</param>
+    /// <param name="count">How many new events are expected</param>
+    /// <param name="clear">Whether to clear all previously recorded events before invoking the delegate</param>
+    protected async Task AssertPostFiresEvent<TEvent>(Action act, EntityUid? uid = null, int count = 1, bool clear = true)
+        where TEvent : notnull
+    {
+        await AssertFiresEvent<TEvent>(async () => await Server.WaitPost(act), uid, count, clear);
+    }
+
+    /// <summary>
+    /// This is a variant of <see cref="AssertFiresEvent{TEvent}"/> that passes the delegate to <see cref="RobustIntegrationTest.ServerIntegrationInstance.WaitAssertion"/>.
+    /// </summary>
+    /// <remarks>
+    /// This currently only checks for server-side events.
+    /// </remarks>
+    /// <param name="uid">The entity at which the events are supposed to be directed</param>
+    /// <param name="count">How many new events are expected</param>
+    /// <param name="clear">Whether to clear all previously recorded events before invoking the delegate</param>
+    protected async Task AssertAssertionFiresEvent<TEvent>(Action act,
+        EntityUid? uid = null,
+        int count = 1,
+        bool clear = true)
+        where TEvent : notnull
+    {
+        await AssertFiresEvent<TEvent>(async () => await Server.WaitAssertion(act), uid, count, clear);
+    }
+
+    /// <summary>
+    /// Asserts that the specified event has been fired some number of times at the given entity (defaults to <see cref="Target"/>).
+    /// For this to work, this requires that the entity has been given a <see cref="TestListenerComponent"/>
+    /// </summary>
+    /// <remarks>
+    /// This currently only checks server-side events.
+    /// </remarks>
+    /// <param name="uid">The entity at which the events were directed</param>
+    /// <param name="count">How many new events are expected</param>
+    /// <param name="predicate">A predicate that can be used to filter the recorded events</param>
+    protected void AssertEvent<TEvent>(EntityUid? uid = null, int count = 1, Func<TEvent,bool>? predicate = null)
+        where TEvent : notnull
+    {
+        Assert.That(GetEvents(uid, predicate).Count, Is.EqualTo(count));
+    }
+
+    /// <summary>
+    /// Gets all the events of the specified type that have been fired at the given entity (defaults to <see cref="Target"/>).
+    /// For this to work, this requires that the entity has been given a <see cref="TestListenerComponent"/>
+    /// </summary>
+    /// <remarks>
+    /// This currently only gets for server-side events.
+    /// </remarks>
+    /// <param name="uid">The entity at which the events were directed</param>
+    /// <param name="predicate">A predicate that can be used to filter the returned events</param>
+    protected IEnumerable<TEvent> GetEvents<TEvent>(EntityUid? uid = null, Func<TEvent, bool>? predicate = null)
+        where TEvent : notnull
+    {
+        uid ??= STarget;
+        if (uid == null)
+        {
+            Assert.Fail("No target specified");
+            return [];
+        }
+
+        Assert.That(SEntMan.HasComponent<TestListenerComponent>(uid), $"Entity must have {nameof(TestListenerComponent)}");
+        return GetListenerSystem<TEvent>().GetEvents(uid.Value, predicate);
+    }
+
+    protected TestListenerSystem<TEvent> GetListenerSystem<TEvent>()
+        where TEvent : notnull
+    {
+        if (_listenerCache.TryGetValue(typeof(TEvent), out var listener))
+            return (TestListenerSystem<TEvent>) listener;
+
+        var type = Server.Resolve<IReflectionManager>().GetAllChildren<TestListenerSystem<TEvent>>().Single();
+        if (!SEntMan.EntitySysManager.TryGetEntitySystem(type, out var systemObj))
+        {
+            // There has to be a listener system that is manually defined. Event subscriptions are locked once
+            // finalized, so we can't really easily create new subscriptions on the fly.
+            // TODO find a better solution
+            throw new InvalidOperationException($"Event {typeof(TEvent).Name} has no associated listener system!");
+        }
+
+        var system = (TestListenerSystem<TEvent>)systemObj;
+        _listenerCache[typeof(TEvent)] = system;
+        return system;
+    }
+
+    /// <summary>
+    /// Clears all recorded events of the given type.
+    /// </summary>
+    protected void ClearEvents<TEvent>(EntityUid uid) where TEvent : notnull
+        => GetListenerSystem<TEvent>().Clear(uid);
+
+    #endregion
+
     #region Entity lookups
 
     /// <summary>
index 7ee895d7c278082f81dfd67ec5be36cb070f8a92..92e4d2471ebb61ae0db6001fcf8467b36e0424e3 100644 (file)
@@ -1,10 +1,8 @@
 #nullable enable
-using System.Collections.Generic;
-using Content.IntegrationTests.Tests.Interaction;
+using Content.IntegrationTests.Tests.Helpers;
 using Content.Shared.Movement.Components;
 using Content.Shared.Slippery;
 using Content.Shared.Stunnable;
-using Robust.Shared.GameObjects;
 using Robust.Shared.Input;
 using Robust.Shared.Maths;
 
@@ -12,44 +10,32 @@ namespace Content.IntegrationTests.Tests.Movement;
 
 public sealed class SlippingTest : MovementTest
 {
-    public sealed class SlipTestSystem : EntitySystem
-    {
-        public HashSet<EntityUid> Slipped = new();
-        public override void Initialize()
-        {
-            SubscribeLocalEvent<SlipperyComponent, SlipEvent>(OnSlip);
-        }
-
-        private void OnSlip(EntityUid uid, SlipperyComponent component, ref SlipEvent args)
-        {
-            Slipped.Add(args.Slipped);
-        }
-    }
+    public sealed class SlipTestSystem : TestListenerSystem<SlipEvent>;
 
     [Test]
     public async Task BananaSlipTest()
     {
-        var sys = SEntMan.System<SlipTestSystem>();
         await SpawnTarget("TrashBananaPeel");
 
         var modifier = Comp<MovementSpeedModifierComponent>(Player).SprintSpeedModifier;
         Assert.That(modifier, Is.EqualTo(1), "Player is not moving at full speed.");
 
-        // Player is to the left of the banana peel and has not slipped.
+        // Player is to the left of the banana peel.
         Assert.That(Delta(), Is.GreaterThan(0.5f));
-        Assert.That(sys.Slipped, Does.Not.Contain(SEntMan.GetEntity(Player)));
 
         // Walking over the banana slowly does not trigger a slip.
         await SetKey(EngineKeyFunctions.Walk, BoundKeyState.Down);
-        await Move(DirectionFlag.East, 1f);
+        await AssertFiresEvent<SlipEvent>(async () => await Move(DirectionFlag.East, 1f), count: 0);
+
         Assert.That(Delta(), Is.LessThan(0.5f));
-        Assert.That(sys.Slipped, Does.Not.Contain(SEntMan.GetEntity(Player)));
         AssertComp<KnockedDownComponent>(false, Player);
 
         // Moving at normal speeds does trigger a slip.
         await SetKey(EngineKeyFunctions.Walk, BoundKeyState.Up);
-        await Move(DirectionFlag.West, 1f);
-        Assert.That(sys.Slipped, Does.Contain(SEntMan.GetEntity(Player)));
+        await AssertFiresEvent<SlipEvent>(async () => await Move(DirectionFlag.West, 1f));
+
+        // And the person that slipped was the player
+        AssertEvent<SlipEvent>(predicate: @event => @event.Slipped == SPlayer);
         AssertComp<KnockedDownComponent>(true, Player);
     }
 }