using AcDream.App.Combat; namespace AcDream.App.Tests.Combat; public sealed class CombatFeedbackSlotTests { [Fact] public void ExpectedOwnerUnbindCannotClearReplacement() { var slot = new CombatFeedbackSlot(); List first = []; List second = []; Action firstTarget = first.Add; Action secondTarget = second.Add; slot.Bind(firstTarget); slot.Unbind(secondTarget); slot.Show("first"); Assert.Equal(["first"], first); Assert.Empty(second); slot.Unbind(firstTarget); slot.Bind(secondTarget); slot.Show("second"); Assert.Equal(["second"], second); } [Fact] public void RebindingADifferentTargetWhileBoundIsRejected() { var slot = new CombatFeedbackSlot(); slot.Bind(_ => { }); Assert.Throws(() => slot.Bind(_ => { })); } [Fact] public void SessionOwnedBindingForwardsAndItsDisposalRestoresSilence() { // #436: the session composition owns the production binding // (SpewBox/ClientLocal) through BindOwned, mirroring // CombatAttackOperationsSlot's lifetime shape — disposal at session // teardown returns the slot to its silent unbound state instead of // leaving a dead session's chat sink reachable. var slot = new CombatFeedbackSlot(); List sink = []; IDisposable binding = slot.BindOwned(sink.Add); slot.Show("routed"); Assert.Equal(["routed"], sink); binding.Dispose(); slot.Show("after-teardown"); Assert.Equal(["routed"], sink); } [Fact] public void SessionOwnedBindingRefusesASecondOwner() { var slot = new CombatFeedbackSlot(); using IDisposable binding = slot.BindOwned(_ => { }); Assert.Throws(() => slot.BindOwned(_ => { })); } [Fact] public void AnUnboundSlotDropsItsMessages() { // Deliberate: before a session composes (and after one tears down) // there is no chat surface, so Show is a no-op rather than a queue — // matching retail, where the refusal text only exists inside a live // session's ExecuteAttack path (0x0056bb70). var slot = new CombatFeedbackSlot(); slot.Show("dropped"); } }