diff --git a/tests/Aetheria.Shared.Tests/CapabilityEventsTests.cs b/tests/Aetheria.Shared.Tests/CapabilityEventsTests.cs new file mode 100644 index 000000000..b89a07637 --- /dev/null +++ b/tests/Aetheria.Shared.Tests/CapabilityEventsTests.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using CultMath; +using UniRx; +using Xunit; +using static CultMath.math; + +// Pins the routing invariant of CapabilityEvents (Assets/Scripts/ServerShared/CapabilityEvents.cs, +// docs/headless-playground-cut.md fork L): a publish of one capability event kind reaches only the +// subscribers of that kind. A subscriber to Absorb must never see a Grab (or Melee, or Thrust) event, +// and vice versa for every other kind. +public sealed class CapabilityEventsTests +{ + [Fact] + public void EachEventKindReachesOnlyItsOwnSubscribers() + { + var events = new CapabilityEvents(); + + var absorbed = new List(); + var grabbed = new List(); + var meleed = new List(); + var thrust = new List(); + + events.Absorb.Subscribe(absorbed.Add); + events.Grab.Subscribe(grabbed.Add); + events.Melee.Subscribe(meleed.Add); + events.Thrust.Subscribe(thrust.Add); + + var absorbEvent = new AbsorbEvent(float3(1, 2, 3), float3(0, 0, 1), 5f, DamageType.Corrosive); + var grabEvent = new GrabEvent(7, GrabPhase.Started, float3(1, 0, 0), 1.5f); + var meleeEvent = new MeleeEvent(float3(0, 0, 1), 10f, 90f, 0.5f); + var thrustEvent = new ThrustEvent(float2(1, 0), 0.5f, float2(0.2f, -0.2f)); + + events.PublishAbsorb(absorbEvent); + events.PublishGrab(grabEvent); + events.PublishMelee(meleeEvent); + events.PublishThrust(thrustEvent); + + // Each stream received exactly one event: its own. + Assert.Equal(new[] { absorbEvent }, absorbed); + Assert.Equal(new[] { grabEvent }, grabbed); + Assert.Equal(new[] { meleeEvent }, meleed); + Assert.Equal(new[] { thrustEvent }, thrust); + + // And nothing extra leaked across kinds: every list has exactly one entry, not more. + Assert.Single(absorbed); + Assert.Single(grabbed); + Assert.Single(meleed); + Assert.Single(thrust); + } +} diff --git a/tests/mutation_tests.py b/tests/mutation_tests.py index c4e137a20..5d951bc8f 100644 --- a/tests/mutation_tests.py +++ b/tests/mutation_tests.py @@ -416,6 +416,24 @@ MUTATIONS: list[Mutation] = [ test="IffAndCombatTests.EligibleWandererFactionsExcludesOwnerAndNearest", expect="red", ), + # --- Capability event contract (docs/headless-playground-cut.md fork L, 2026-09-17): each event + # kind must reach only its own subscribers. --- + Mutation( + rule="control (no-op round trip) for the capability event routing test", + file="Assets/Scripts/ServerShared/CapabilityEvents.cs", + anchor=" public void PublishAbsorb(AbsorbEvent e) => _absorb.OnNext(e);", + mutated=" public void PublishAbsorb(AbsorbEvent e) => _absorb.OnNext(e);", + test="CapabilityEventsTests.EachEventKindReachesOnlyItsOwnSubscribers", + expect="green", + ), + Mutation( + rule="PublishAbsorb notifies only the Absorb stream, not Thrust as well", + file="Assets/Scripts/ServerShared/CapabilityEvents.cs", + anchor=" public void PublishAbsorb(AbsorbEvent e) => _absorb.OnNext(e);", + mutated=" public void PublishAbsorb(AbsorbEvent e) { _absorb.OnNext(e); _thrust.OnNext(default); }", + test="CapabilityEventsTests.EachEventKindReachesOnlyItsOwnSubscribers", + expect="red", + ), ]