441 lines
17 KiB
C#
441 lines
17 KiB
C#
#nullable enable
|
|
|
|
using System;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using System.Threading;
|
|
using Cysharp.Threading.Tasks;
|
|
using NUnit.Framework;
|
|
using UnityEngine;
|
|
using UnityEngine.TestTools;
|
|
|
|
namespace ShrinkEventBus.Tests
|
|
{
|
|
internal readonly struct GeneratedPingEvent : IShrinkEvent
|
|
{
|
|
public GeneratedPingEvent(int value) => Value = value;
|
|
public int Value { get; }
|
|
}
|
|
|
|
[ShrinkEventSubscriber(DefaultBus = "game")]
|
|
internal sealed class CodeGeneratedTarget
|
|
{
|
|
public int Sum { get; private set; }
|
|
|
|
[ShrinkSubscribe]
|
|
private void OnPing(GeneratedPingEvent evt) => Sum += evt.Value;
|
|
}
|
|
|
|
[ShrinkEventSubscriber(DefaultBus = "game")]
|
|
internal static class StaticCodeGeneratedTarget
|
|
{
|
|
public static int Sum { get; set; }
|
|
|
|
[ShrinkSubscribe]
|
|
private static void OnPing(GeneratedPingEvent evt) => Sum += evt.Value;
|
|
}
|
|
|
|
[ShrinkEventSubscriber(DefaultBus = "mod:late-static")]
|
|
internal static class DelayedStaticCodeGeneratedTarget
|
|
{
|
|
public static int Sum { get; set; }
|
|
|
|
[ShrinkSubscribe]
|
|
private static void OnPing(GeneratedPingEvent evt) => Sum += evt.Value;
|
|
}
|
|
|
|
[ShrinkEventSubscriber(DefaultBus = "game")]
|
|
internal sealed class MonoCodeGeneratedTarget : MonoBehaviour
|
|
{
|
|
public int Sum { get; private set; }
|
|
|
|
[ShrinkSubscribe]
|
|
private void OnPing(GeneratedPingEvent evt) => Sum += evt.Value;
|
|
}
|
|
|
|
public sealed class ShrinkEventBusV2Tests
|
|
{
|
|
private readonly struct PingEvent : IShrinkEvent
|
|
{
|
|
public PingEvent(int value) => Value = value;
|
|
public int Value { get; }
|
|
}
|
|
|
|
private readonly struct DiagnosticEvent : IShrinkEvent
|
|
{
|
|
}
|
|
|
|
private sealed class CancelEvent : IShrinkCancelableEvent
|
|
{
|
|
public bool IsCanceled { get; private set; }
|
|
public void SetCanceled(bool value) => IsCanceled = value;
|
|
}
|
|
|
|
private sealed class ResultEvent : IShrinkResultEvent<int>
|
|
{
|
|
public int Result { get; private set; }
|
|
public void SetResult(int result) => Result = result;
|
|
}
|
|
|
|
private sealed class GeneratedTarget : IShrinkGeneratedSubscriber
|
|
{
|
|
public int Sum;
|
|
|
|
public IDisposable AttachGenerated(IShrinkBusResolver resolver, ShrinkBusKey? defaultBus = null)
|
|
{
|
|
var binding = new ShrinkEventBinding();
|
|
binding.Add(ShrinkGeneratedBinding.Subscribe<PingEvent>(resolver, defaultBus, string.Empty,
|
|
this, OnPing, ShrinkEventPriority.Normal, 0, false));
|
|
return binding;
|
|
}
|
|
|
|
private void OnPing(PingEvent evt) => Sum += evt.Value;
|
|
}
|
|
|
|
[Test]
|
|
public void MultipleBusesAreIsolated()
|
|
{
|
|
using var host = new ShrinkEventBusHost();
|
|
var game = host.CreateBus(ShrinkBusKey.Game, ShrinkBusOptions.Inline());
|
|
var mod = host.CreateBus(ShrinkBusKey.Mod("sample"), ShrinkBusOptions.Inline());
|
|
var gameHits = 0;
|
|
var modHits = 0;
|
|
using var gameBinding = ShrinkGeneratedBinding.Subscribe<PingEvent>(host, ShrinkBusKey.Game,
|
|
string.Empty, this, evt => gameHits += evt.Value, ShrinkEventPriority.Normal, 0, false);
|
|
using var modBinding = ShrinkGeneratedBinding.Subscribe<PingEvent>(host, ShrinkBusKey.Mod("sample"),
|
|
string.Empty, this, evt => modHits += evt.Value, ShrinkEventPriority.Normal, 0, false);
|
|
|
|
game.Post(new PingEvent(2));
|
|
mod.Post(new PingEvent(3));
|
|
|
|
Assert.AreEqual(2, gameHits);
|
|
Assert.AreEqual(3, modHits);
|
|
}
|
|
|
|
[Test]
|
|
public void GeneratedTargetAttachDisposesAllHandlers()
|
|
{
|
|
using var host = new ShrinkEventBusHost();
|
|
var game = host.CreateBus(ShrinkBusKey.Game, ShrinkBusOptions.Inline());
|
|
var target = new GeneratedTarget();
|
|
var binding = host.Attach(target, ShrinkBusKey.Game);
|
|
|
|
game.Post(new PingEvent(4));
|
|
binding.Dispose();
|
|
game.Post(new PingEvent(7));
|
|
|
|
Assert.AreEqual(4, target.Sum);
|
|
}
|
|
|
|
[Test]
|
|
public void AttributeOnlyTargetUsesIlGeneratedBinding()
|
|
{
|
|
using var host = new ShrinkEventBusHost();
|
|
var game = host.CreateBus(ShrinkBusKey.Game, ShrinkBusOptions.Inline());
|
|
var target = new CodeGeneratedTarget();
|
|
|
|
using (host.Attach(target, ShrinkBusKey.Game))
|
|
game.Post(new GeneratedPingEvent(9));
|
|
game.Post(new GeneratedPingEvent(4));
|
|
|
|
Assert.AreEqual(9, target.Sum);
|
|
}
|
|
|
|
[Test]
|
|
public void StaticAttributeSubscriberUsesModuleInitializerBinding()
|
|
{
|
|
StaticCodeGeneratedTarget.Sum = 0;
|
|
|
|
EventBus.Post(new GeneratedPingEvent(11));
|
|
|
|
Assert.AreEqual(11, StaticCodeGeneratedTarget.Sum);
|
|
}
|
|
|
|
[Test]
|
|
public void StaticSubscriberAttachesWhenNamedBusIsCreatedLater()
|
|
{
|
|
var key = ShrinkBusKey.Mod("late-static");
|
|
EventBus.RemoveBus(key);
|
|
DelayedStaticCodeGeneratedTarget.Sum = 0;
|
|
var bus = EventBus.CreateBus(key, ShrinkBusOptions.Inline());
|
|
|
|
bus.Post(new GeneratedPingEvent(13));
|
|
|
|
Assert.AreEqual(13, DelayedStaticCodeGeneratedTarget.Sum);
|
|
Assert.IsTrue(EventBus.RemoveBus(key));
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator MonoScopeRefreshAndReleaseControlGeneratedBindings()
|
|
{
|
|
var gameObject = new GameObject("ShrinkEventBusV2-MonoScope");
|
|
gameObject.SetActive(false);
|
|
var target = gameObject.AddComponent<MonoCodeGeneratedTarget>();
|
|
var scope = gameObject.AddComponent<ShrinkMonoEventScope>();
|
|
Assert.IsInstanceOf<IShrinkGeneratedSubscriber>(target);
|
|
|
|
scope.RefreshBindings();
|
|
yield return null;
|
|
yield return EventBus.PostAsync(new GeneratedPingEvent(5)).ToCoroutine();
|
|
Assert.AreEqual(5, target.Sum);
|
|
|
|
scope.ReleaseBindings();
|
|
yield return EventBus.PostAsync(new GeneratedPingEvent(7)).ToCoroutine();
|
|
Assert.AreEqual(5, target.Sum);
|
|
UnityEngine.Object.DestroyImmediate(gameObject);
|
|
}
|
|
|
|
[Test]
|
|
public void CanceledEventsSkipNormalHandlersButReachMonitors()
|
|
{
|
|
using var host = new ShrinkEventBusHost();
|
|
var game = host.CreateBus(ShrinkBusKey.Game, ShrinkBusOptions.Inline());
|
|
var normal = 0;
|
|
var monitor = 0;
|
|
using var first = ShrinkGeneratedBinding.Subscribe<CancelEvent>(host, ShrinkBusKey.Game,
|
|
string.Empty, this, evt => evt.SetCanceled(true), ShrinkEventPriority.Highest, 0, false);
|
|
using var second = ShrinkGeneratedBinding.Subscribe<CancelEvent>(host, ShrinkBusKey.Game,
|
|
string.Empty, this, _ => normal++, ShrinkEventPriority.Normal, 0, false);
|
|
using var third = ShrinkGeneratedBinding.Subscribe<CancelEvent>(host, ShrinkBusKey.Game,
|
|
string.Empty, this, _ => monitor++, ShrinkEventPriority.Monitor, 0, true);
|
|
|
|
var result = game.Post(new CancelEvent());
|
|
|
|
Assert.IsTrue(result.Canceled);
|
|
Assert.AreEqual(0, normal);
|
|
Assert.AreEqual(1, monitor);
|
|
}
|
|
|
|
[Test]
|
|
public void PriorityAndNumericPriorityAreStable()
|
|
{
|
|
using var host = new ShrinkEventBusHost();
|
|
var game = host.CreateBus(ShrinkBusKey.Game, ShrinkBusOptions.Inline());
|
|
var order = new List<string>();
|
|
using var normal = ShrinkGeneratedBinding.Subscribe<PingEvent>(host, ShrinkBusKey.Game,
|
|
string.Empty, this, _ => order.Add("normal"), ShrinkEventPriority.Normal, 0, false);
|
|
using var high = ShrinkGeneratedBinding.Subscribe<PingEvent>(host, ShrinkBusKey.Game,
|
|
string.Empty, this, _ => order.Add("high"), ShrinkEventPriority.High, 0, false);
|
|
using var numeric = ShrinkGeneratedBinding.Subscribe<PingEvent>(host, ShrinkBusKey.Game,
|
|
string.Empty, this, _ => order.Add("numeric"), ShrinkEventPriority.Normal, 75, false);
|
|
|
|
game.Post(new PingEvent(1));
|
|
|
|
CollectionAssert.AreEqual(new[] { "numeric", "high", "normal" }, order);
|
|
}
|
|
|
|
[Test]
|
|
public void ResultEventUsesTypedResultContract()
|
|
{
|
|
using var host = new ShrinkEventBusHost();
|
|
var game = host.CreateBus(ShrinkBusKey.Game, ShrinkBusOptions.Inline());
|
|
using var binding = ShrinkGeneratedBinding.Subscribe<ResultEvent>(host, ShrinkBusKey.Game,
|
|
string.Empty, this, value => value.SetResult(42),
|
|
ShrinkEventPriority.Normal, 0, false);
|
|
var value = new ResultEvent();
|
|
|
|
var result = game.Post(value);
|
|
|
|
Assert.IsTrue(result.Handled);
|
|
Assert.AreEqual(42, value.Result);
|
|
}
|
|
|
|
[Test]
|
|
public void HandlerExceptionPropagatesFromPost()
|
|
{
|
|
using var host = new ShrinkEventBusHost();
|
|
var game = host.CreateBus(ShrinkBusKey.Game, ShrinkBusOptions.Inline());
|
|
using var binding = ShrinkGeneratedBinding.Subscribe<PingEvent>(host, ShrinkBusKey.Game,
|
|
string.Empty, this, _ => throw new InvalidOperationException("expected"),
|
|
ShrinkEventPriority.Normal, 0, false);
|
|
|
|
var exception = Assert.Throws<InvalidOperationException>(() => game.Post(new PingEvent(1)));
|
|
|
|
Assert.AreEqual("expected", exception!.Message);
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator PostAsyncCancellationReturnsCanceledFailure()
|
|
{
|
|
using var host = new ShrinkEventBusHost();
|
|
var bus = host.CreateBus(new ShrinkBusKey("worker", "cancellation"),
|
|
ShrinkBusOptions.DedicatedThread());
|
|
using var source = new CancellationTokenSource();
|
|
source.Cancel();
|
|
ShrinkPostResult result = default;
|
|
|
|
yield return bus.PostAsync(new PingEvent(1), source.Token)
|
|
.ContinueWith(value => result = value)
|
|
.ToCoroutine();
|
|
|
|
Assert.AreEqual(ShrinkPostFailure.Canceled, result.Failure);
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator DedicatedThreadShutdownDrainsAcceptedWork()
|
|
{
|
|
var host = new ShrinkEventBusHost();
|
|
var key = new ShrinkBusKey("worker", "drain");
|
|
var bus = host.CreateBus(key, ShrinkBusOptions.DedicatedThread(queueCapacity: 4));
|
|
var hits = 0;
|
|
using var binding = ShrinkGeneratedBinding.Subscribe<PingEvent>(host, key,
|
|
string.Empty, this, _ => Interlocked.Increment(ref hits),
|
|
ShrinkEventPriority.Normal, 0, false);
|
|
Assert.IsTrue(bus.Post(new PingEvent(1)).Accepted);
|
|
Assert.IsTrue(bus.Post(new PingEvent(2)).Accepted);
|
|
|
|
yield return host.ShutdownAsync().ToCoroutine();
|
|
|
|
Assert.AreEqual(2, Volatile.Read(ref hits));
|
|
}
|
|
|
|
[Test]
|
|
public void DedicatedThreadRejectsWhenBoundedQueueIsFull()
|
|
{
|
|
using var host = new ShrinkEventBusHost();
|
|
var key = new ShrinkBusKey("worker", "bounded");
|
|
var options = ShrinkBusOptions.DedicatedThread(queueCapacity: 1);
|
|
options.OverflowPolicy = ShrinkQueueOverflowPolicy.Reject;
|
|
var bus = host.CreateBus(key, options);
|
|
using var entered = new ManualResetEventSlim(false);
|
|
using var release = new ManualResetEventSlim(false);
|
|
var hits = 0;
|
|
using var binding = ShrinkGeneratedBinding.Subscribe<PingEvent>(host, key, string.Empty,
|
|
this, _ =>
|
|
{
|
|
Interlocked.Increment(ref hits);
|
|
entered.Set();
|
|
release.Wait(TimeSpan.FromSeconds(2));
|
|
}, ShrinkEventPriority.Normal, 0, false);
|
|
|
|
Assert.IsTrue(bus.Post(new PingEvent(1)).Accepted);
|
|
Assert.IsTrue(entered.Wait(TimeSpan.FromSeconds(2)));
|
|
Assert.IsTrue(bus.Post(new PingEvent(2)).Accepted);
|
|
var rejected = bus.Post(new PingEvent(3));
|
|
release.Set();
|
|
|
|
Assert.IsFalse(rejected.Accepted);
|
|
Assert.AreEqual(ShrinkPostFailure.QueueFull, rejected.Failure);
|
|
Assert.IsTrue(SpinWait.SpinUntil(() => Volatile.Read(ref hits) == 2, TimeSpan.FromSeconds(2)));
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator TaskPoolHonorsMaximumConcurrency()
|
|
{
|
|
using var host = new ShrinkEventBusHost();
|
|
var key = new ShrinkBusKey("worker", "parallel");
|
|
var bus = host.CreateBus(key, ShrinkBusOptions.TaskPool(maxConcurrency: 2, queueCapacity: 8));
|
|
var active = 0;
|
|
var maxActive = 0;
|
|
using var binding = ShrinkGeneratedBinding.SubscribeAsync<PingEvent>(host, key, string.Empty,
|
|
this, async (_, _) =>
|
|
{
|
|
var current = Interlocked.Increment(ref active);
|
|
UpdateMaximum(ref maxActive, current);
|
|
Thread.Sleep(30);
|
|
Interlocked.Decrement(ref active);
|
|
await UniTask.CompletedTask;
|
|
}, ShrinkEventPriority.Normal, 0, false);
|
|
var posts = new List<UniTask<ShrinkPostResult>>();
|
|
for (var i = 0; i < 4; i++)
|
|
posts.Add(bus.PostAsync(new PingEvent(i)));
|
|
|
|
yield return UniTask.WhenAll(posts).ToCoroutine();
|
|
|
|
Assert.AreEqual(2, maxActive);
|
|
}
|
|
|
|
[Test]
|
|
public void WarmStructPostDoesNotAllocate()
|
|
{
|
|
using var host = new ShrinkEventBusHost();
|
|
var game = host.CreateBus(ShrinkBusKey.Game, ShrinkBusOptions.Inline());
|
|
var sum = 0;
|
|
using var binding = ShrinkGeneratedBinding.Subscribe<PingEvent>(host, ShrinkBusKey.Game,
|
|
string.Empty, this, evt => sum += evt.Value, ShrinkEventPriority.Normal, 0, false);
|
|
for (var i = 0; i < 100; i++)
|
|
game.Post(new PingEvent(i));
|
|
|
|
var before = GC.GetAllocatedBytesForCurrentThread();
|
|
for (var i = 0; i < 10_000; i++)
|
|
game.Post(new PingEvent(i));
|
|
var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
|
|
|
Assert.AreEqual(0, allocated);
|
|
Assert.Greater(sum, 0);
|
|
}
|
|
|
|
[Test]
|
|
public void GlobalDetailedObserverReportsDispatchMetadata()
|
|
{
|
|
var captured = false;
|
|
var trace = default(ShrinkEventTrace);
|
|
void Capture(ShrinkEventTrace value)
|
|
{
|
|
trace = value;
|
|
captured = true;
|
|
}
|
|
|
|
EventBus.DetailedPosted += Capture;
|
|
try
|
|
{
|
|
EventBus.Post(new DiagnosticEvent());
|
|
}
|
|
finally
|
|
{
|
|
EventBus.DetailedPosted -= Capture;
|
|
}
|
|
|
|
Assert.IsTrue(captured);
|
|
Assert.AreEqual(typeof(DiagnosticEvent), trace.EventType);
|
|
Assert.AreEqual(ShrinkBusKey.Game, trace.BusKey);
|
|
Assert.AreEqual(ShrinkBusSchedulerKind.MainThread, trace.Scheduler);
|
|
Assert.IsTrue(trace.Result.Accepted);
|
|
Assert.GreaterOrEqual(trace.ElapsedTimestampTicks, 0L);
|
|
}
|
|
|
|
[Test]
|
|
public void GlobalStructPostDoesNotAllocateWithoutObservers()
|
|
{
|
|
var value = new DiagnosticEvent();
|
|
for (var i = 0; i < 100; i++)
|
|
EventBus.Post(in value);
|
|
|
|
var before = GC.GetAllocatedBytesForCurrentThread();
|
|
for (var i = 0; i < 10_000; i++)
|
|
EventBus.Post(in value);
|
|
var allocated = GC.GetAllocatedBytesForCurrentThread() - before;
|
|
|
|
Assert.AreEqual(0, allocated);
|
|
}
|
|
|
|
[UnityTest]
|
|
public IEnumerator DedicatedThreadPostAsyncRunsOnDedicatedThread()
|
|
{
|
|
using var host = new ShrinkEventBusHost();
|
|
var bus = host.CreateBus(new ShrinkBusKey("worker", "tests"),
|
|
ShrinkBusOptions.DedicatedThread());
|
|
var callerThread = Thread.CurrentThread.ManagedThreadId;
|
|
var handlerThread = callerThread;
|
|
using var binding = ShrinkGeneratedBinding.Subscribe<PingEvent>(host,
|
|
new ShrinkBusKey("worker", "tests"), string.Empty, this,
|
|
_ => handlerThread = Thread.CurrentThread.ManagedThreadId,
|
|
ShrinkEventPriority.Normal, 0, false);
|
|
|
|
yield return bus.PostAsync(new PingEvent(1)).ToCoroutine();
|
|
|
|
Assert.AreNotEqual(callerThread, handlerThread);
|
|
}
|
|
|
|
private static void UpdateMaximum(ref int location, int candidate)
|
|
{
|
|
while (true)
|
|
{
|
|
var current = Volatile.Read(ref location);
|
|
if (candidate <= current || Interlocked.CompareExchange(ref location, candidate, current) == current)
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
}
|