From dac2c5212ae1c5f230f04dc31e7ade293dc8dcae Mon Sep 17 00:00:00 2001
From: Leon Friedrich <60421075+ElectroJr@users.noreply.github.com>
Date: Fri, 10 Oct 2025 02:03:44 +1300
Subject: [PATCH] Add generic event listener for integration tests (#40367)
* Add generic event listener for integration tests
* cleanup
* assert that the entity has the component
* comments & new overload
---
.../Tests/Helpers/TestListenerComponent.cs | 13 ++
.../Tests/Helpers/TestListenerSystem.cs | 45 ++++++
.../Interaction/InteractionTest.Helpers.cs | 138 ++++++++++++++++++
.../Tests/Movement/SlippingTest.cs | 32 ++--
4 files changed, 205 insertions(+), 23 deletions(-)
create mode 100644 Content.IntegrationTests/Tests/Helpers/TestListenerComponent.cs
create mode 100644 Content.IntegrationTests/Tests/Helpers/TestListenerSystem.cs
diff --git a/Content.IntegrationTests/Tests/Helpers/TestListenerComponent.cs b/Content.IntegrationTests/Tests/Helpers/TestListenerComponent.cs
new file mode 100644
index 0000000000..817558b426
--- /dev/null
+++ b/Content.IntegrationTests/Tests/Helpers/TestListenerComponent.cs
@@ -0,0 +1,13 @@
+using System.Collections.Generic;
+using Robust.Shared.GameObjects;
+
+namespace Content.IntegrationTests.Tests.Helpers;
+
+///
+/// Component that is used by to store any information about received events.
+///
+[RegisterComponent]
+public sealed partial class TestListenerComponent : Component
+{
+ public Dictionary> Events = new();
+}
diff --git a/Content.IntegrationTests/Tests/Helpers/TestListenerSystem.cs b/Content.IntegrationTests/Tests/Helpers/TestListenerSystem.cs
new file mode 100644
index 0000000000..2481cef03f
--- /dev/null
+++ b/Content.IntegrationTests/Tests/Helpers/TestListenerSystem.cs
@@ -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;
+
+///
+/// Generic system that listens for and records any received events of a given type.
+///
+public abstract class TestListenerSystem : 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(OnDirectedEvent);
+ }
+
+ protected virtual void OnDirectedEvent(Entity ent, ref TEvent args)
+ {
+ ent.Comp.Events.GetOrNew(args.GetType()).Add(args);
+ }
+
+ public int Count(EntityUid uid, Func? predicate = null)
+ {
+ return GetEvents(uid, predicate).Count();
+ }
+
+ public void Clear(EntityUid uid)
+ {
+ CompOrNull(uid)?.Events.Remove(typeof(TEvent));
+ }
+
+ public IEnumerable GetEvents(EntityUid uid, Func? predicate = null)
+ {
+ var events = CompOrNull(uid)?.Events.GetValueOrDefault(typeof(TEvent));
+ if (events == null)
+ return [];
+
+ return events.Cast().Where(e => predicate?.Invoke(e) ?? true);
+ }
+}
diff --git a/Content.IntegrationTests/Tests/Interaction/InteractionTest.Helpers.cs b/Content.IntegrationTests/Tests/Interaction/InteractionTest.Helpers.cs
index fa16730dd5..d04ed4cb3c 100644
--- a/Content.IntegrationTests/Tests/Interaction/InteractionTest.Helpers.cs
+++ b/Content.IntegrationTests/Tests/Interaction/InteractionTest.Helpers.cs
@@ -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 _listenerCache = new();
+
///
/// Begin constructing an entity.
///
@@ -758,6 +763,139 @@ public abstract partial class InteractionTest
#endregion
+ #region EventListener
+
+ ///
+ /// Asserts that running the given action causes an event to be fired directed at the specified entity (defaults to ).
+ ///
+ ///
+ /// This currently only checks server-side events.
+ ///
+ /// The entity at which the events are supposed to be directed
+ /// How many new events are expected
+ /// Whether to clear all previously recorded events before invoking the delegate
+ protected async Task AssertFiresEvent(Func act, EntityUid? uid = null, int count = 1, bool clear = true)
+ where TEvent : notnull
+ {
+ var sys = GetListenerSystem();
+
+ 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(uid.Value));
+ await act();
+ AssertEvent(uid, count: count);
+ }
+
+ ///
+ /// This is a variant of that passes the delegate to .
+ ///
+ ///
+ /// This currently only checks for server-side events.
+ ///
+ /// The entity at which the events are supposed to be directed
+ /// How many new events are expected
+ /// Whether to clear all previously recorded events before invoking the delegate
+ protected async Task AssertPostFiresEvent(Action act, EntityUid? uid = null, int count = 1, bool clear = true)
+ where TEvent : notnull
+ {
+ await AssertFiresEvent(async () => await Server.WaitPost(act), uid, count, clear);
+ }
+
+ ///
+ /// This is a variant of that passes the delegate to .
+ ///
+ ///
+ /// This currently only checks for server-side events.
+ ///
+ /// The entity at which the events are supposed to be directed
+ /// How many new events are expected
+ /// Whether to clear all previously recorded events before invoking the delegate
+ protected async Task AssertAssertionFiresEvent(Action act,
+ EntityUid? uid = null,
+ int count = 1,
+ bool clear = true)
+ where TEvent : notnull
+ {
+ await AssertFiresEvent(async () => await Server.WaitAssertion(act), uid, count, clear);
+ }
+
+ ///
+ /// Asserts that the specified event has been fired some number of times at the given entity (defaults to ).
+ /// For this to work, this requires that the entity has been given a
+ ///
+ ///
+ /// This currently only checks server-side events.
+ ///
+ /// The entity at which the events were directed
+ /// How many new events are expected
+ /// A predicate that can be used to filter the recorded events
+ protected void AssertEvent(EntityUid? uid = null, int count = 1, Func? predicate = null)
+ where TEvent : notnull
+ {
+ Assert.That(GetEvents(uid, predicate).Count, Is.EqualTo(count));
+ }
+
+ ///
+ /// Gets all the events of the specified type that have been fired at the given entity (defaults to ).
+ /// For this to work, this requires that the entity has been given a
+ ///
+ ///
+ /// This currently only gets for server-side events.
+ ///
+ /// The entity at which the events were directed
+ /// A predicate that can be used to filter the returned events
+ protected IEnumerable GetEvents(EntityUid? uid = null, Func? predicate = null)
+ where TEvent : notnull
+ {
+ uid ??= STarget;
+ if (uid == null)
+ {
+ Assert.Fail("No target specified");
+ return [];
+ }
+
+ Assert.That(SEntMan.HasComponent(uid), $"Entity must have {nameof(TestListenerComponent)}");
+ return GetListenerSystem().GetEvents(uid.Value, predicate);
+ }
+
+ protected TestListenerSystem GetListenerSystem()
+ where TEvent : notnull
+ {
+ if (_listenerCache.TryGetValue(typeof(TEvent), out var listener))
+ return (TestListenerSystem) listener;
+
+ var type = Server.Resolve().GetAllChildren>().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)systemObj;
+ _listenerCache[typeof(TEvent)] = system;
+ return system;
+ }
+
+ ///
+ /// Clears all recorded events of the given type.
+ ///
+ protected void ClearEvents(EntityUid uid) where TEvent : notnull
+ => GetListenerSystem().Clear(uid);
+
+ #endregion
+
#region Entity lookups
///
diff --git a/Content.IntegrationTests/Tests/Movement/SlippingTest.cs b/Content.IntegrationTests/Tests/Movement/SlippingTest.cs
index 7ee895d7c2..92e4d2471e 100644
--- a/Content.IntegrationTests/Tests/Movement/SlippingTest.cs
+++ b/Content.IntegrationTests/Tests/Movement/SlippingTest.cs
@@ -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 Slipped = new();
- public override void Initialize()
- {
- SubscribeLocalEvent(OnSlip);
- }
-
- private void OnSlip(EntityUid uid, SlipperyComponent component, ref SlipEvent args)
- {
- Slipped.Add(args.Slipped);
- }
- }
+ public sealed class SlipTestSystem : TestListenerSystem;
[Test]
public async Task BananaSlipTest()
{
- var sys = SEntMan.System();
await SpawnTarget("TrashBananaPeel");
var modifier = Comp(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(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(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(async () => await Move(DirectionFlag.West, 1f));
+
+ // And the person that slipped was the player
+ AssertEvent(predicate: @event => @event.Slipped == SPlayer);
AssertComp(true, Player);
}
}
--
2.51.2