#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 { 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(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(host, ShrinkBusKey.Game, string.Empty, this, evt => gameHits += evt.Value, ShrinkEventPriority.Normal, 0, false); using var modBinding = ShrinkGeneratedBinding.Subscribe(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(); var scope = gameObject.AddComponent(); Assert.IsInstanceOf(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(host, ShrinkBusKey.Game, string.Empty, this, evt => evt.SetCanceled(true), ShrinkEventPriority.Highest, 0, false); using var second = ShrinkGeneratedBinding.Subscribe(host, ShrinkBusKey.Game, string.Empty, this, _ => normal++, ShrinkEventPriority.Normal, 0, false); using var third = ShrinkGeneratedBinding.Subscribe(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(); using var normal = ShrinkGeneratedBinding.Subscribe(host, ShrinkBusKey.Game, string.Empty, this, _ => order.Add("normal"), ShrinkEventPriority.Normal, 0, false); using var high = ShrinkGeneratedBinding.Subscribe(host, ShrinkBusKey.Game, string.Empty, this, _ => order.Add("high"), ShrinkEventPriority.High, 0, false); using var numeric = ShrinkGeneratedBinding.Subscribe(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(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(host, ShrinkBusKey.Game, string.Empty, this, _ => throw new InvalidOperationException("expected"), ShrinkEventPriority.Normal, 0, false); var exception = Assert.Throws(() => 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(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(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(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>(); 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(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(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; } } } }