using AcDream.App.Input; using AcDream.App.Rendering; using AcDream.Core.Combat; using AcDream.UI.Abstractions.Input; namespace AcDream.App.Tests.Input; public sealed class GameplayInputActionRouterTests { [Theory] [InlineData("pointer", "pointer")] [InlineData("combat", "pointer,combat")] [InlineData("retained", "pointer,combat,retained")] [InlineData("selection", "pointer,combat,retained,selection")] [InlineData("movement", "pointer,combat,retained,selection,movement")] [InlineData("command", "pointer,combat,retained,selection,movement,command")] public void Press_PreservesFrozenPriorityAndStopsAtConsumer( string consumeAt, string expectedCsv) { var harness = new Harness(consumeAt); harness.Router.Attach(); harness.Actions.Raise( InputAction.AcdreamToggleDebugPanel, ActivationType.Press); Assert.Equal(expectedCsv.Split(','), harness.Targets.Calls); } [Fact] public void Scroll_IsBetweenPointerAndCombat_AndOnlyPressMovesIt() { var harness = new Harness(); harness.Router.Attach(); harness.Actions.Raise(InputAction.ScrollUp, ActivationType.Release); Assert.Equal(["pointer"], harness.Targets.Calls); harness.Targets.Calls.Clear(); harness.Actions.Raise(InputAction.ScrollDown, ActivationType.Press); Assert.Equal(["pointer", "scroll:ScrollDown"], harness.Targets.Calls); } [Fact] public void Release_ReachesCombatThenStopsAtPressGate() { var harness = new Harness(); harness.Router.Attach(); harness.Actions.Raise( InputAction.CombatHighAttack, ActivationType.Release); Assert.Equal(["pointer", "combat"], harness.Targets.Calls); } [Fact] public void DoubleClick_PassesGateToRetainedSelectionMovementAndCommands() { var harness = new Harness(); harness.Router.Attach(); harness.Actions.Raise( InputAction.SelectDblLeft, ActivationType.DoubleClick); Assert.Equal( ["pointer", "combat", "retained", "selection", "movement", "command"], harness.Targets.Calls); } [Fact] public void Attach_SeedsAndTracksCombatScopes() { var harness = new Harness(); harness.Combat.CurrentModeValue = CombatMode.Missile; harness.Router.Attach(); harness.Combat.Raise(CombatMode.Magic); harness.Combat.Raise(CombatMode.NonCombat); Assert.Equal( [InputScope.MissileCombat, InputScope.MagicCombat, null], harness.Actions.Scopes); } [Theory] [InlineData(0, "remove-actions")] [InlineData(1, "remove-combat,remove-actions")] public void AttachFailureAfterSideEffect_RollsBackExactPrefixInReverse( int failingEdge, string expectedRemovalCsv) { var calls = new List(); var actions = new FakeActionSurface(calls) { ThrowAfterAdd = failingEdge == 0, }; var combat = new FakeCombatSurface(calls) { ThrowAfterAdd = failingEdge == 1, }; var router = Create(actions, combat, new FakeTargets()); Assert.Throws(router.Attach); Assert.Equal(expectedRemovalCsv.Split(','), calls.Where( call => call.StartsWith("remove", StringComparison.Ordinal))); Assert.True(router.IsDisposalComplete); } [Fact] public void ReentrantDeliveryDuringAdd_CannotEnterPartialRouter() { var calls = new List(); var actions = new FakeActionSurface(calls) { RaiseDuringAdd = true, }; var targets = new FakeTargets(); var router = Create(actions, new FakeCombatSurface(calls), targets); router.Attach(); Assert.Empty(targets.Calls); actions.Raise(InputAction.AcdreamToggleDebugPanel, ActivationType.Press); Assert.NotEmpty(targets.Calls); } [Fact] public void AttachAndRollbackFailure_RetainsCleanupForLaterDispose() { var calls = new List(); var actions = new FakeActionSurface(calls) { ThrowAfterAdd = true, RemoveFailures = int.MaxValue, }; var router = Create( actions, new FakeCombatSurface(calls), new FakeTargets()); Assert.Throws(router.Attach); Assert.False(router.IsDisposalComplete); actions.RemoveFailures = 0; router.Dispose(); Assert.True(router.IsDisposalComplete); Assert.Null(actions.Callback); } [Fact] public void Dispose_RetriesOnlyFailedRemovalAndNeverReplaysSuccess() { var calls = new List(); var actions = new FakeActionSurface(calls); var combat = new FakeCombatSurface(calls) { RemoveFailures = int.MaxValue, }; var router = Create(actions, combat, new FakeTargets()); router.Attach(); Assert.Throws(router.Dispose); Assert.False(router.IsDisposalComplete); combat.RemoveFailures = 0; router.Dispose(); Assert.True(router.IsDisposalComplete); Assert.Equal(4, calls.Count(call => call == "remove-combat")); Assert.Equal(1, calls.Count(call => call == "remove-actions")); } [Fact] public void Deactivate_MakesCopiedCallbacksSilentBeforePhysicalDetach() { var harness = new Harness(); harness.Router.Attach(); Action copiedAction = harness.Actions.Callback!; Action copiedCombat = harness.Combat.Callback!; harness.Router.Deactivate(); copiedAction(InputAction.AcdreamToggleDebugPanel, ActivationType.Press); copiedCombat(CombatMode.Melee); Assert.Empty(harness.Targets.Calls); Assert.Equal([null], harness.Actions.Scopes); } [Fact] public void DisposeBeforeAttach_IsTerminalAndCannotResurrectCallbacks() { var harness = new Harness(); harness.Router.Dispose(); Assert.True(harness.Router.IsDisposalComplete); Assert.Throws(harness.Router.Attach); Assert.Null(harness.Actions.Callback); Assert.Null(harness.Combat.Callback); } private static GameplayInputActionRouter Create( FakeActionSurface actions, FakeCombatSurface combat, FakeTargets targets) => new(actions, combat, targets, new HostQuiescenceGate()); private sealed class Harness { public Harness(string? consumeAt = null) { var calls = new List(); Actions = new FakeActionSurface(calls); Combat = new FakeCombatSurface(calls); Targets = new FakeTargets(consumeAt); Router = Create(Actions, Combat, Targets); } public FakeActionSurface Actions { get; } public FakeCombatSurface Combat { get; } public FakeTargets Targets { get; } public GameplayInputActionRouter Router { get; } } private sealed class FakeActionSurface(List calls) : IGameplayInputActionSurface { public Action? Callback { get; private set; } public bool ThrowAfterAdd { get; set; } public bool RaiseDuringAdd { get; set; } public int RemoveFailures { get; set; } public List Scopes { get; } = []; public void AddFired(Action callback) { calls.Add("add-actions"); Callback = callback; if (RaiseDuringAdd) { callback( InputAction.AcdreamToggleDebugPanel, ActivationType.Press); } if (ThrowAfterAdd) throw new InvalidOperationException("actions add"); } public void RemoveFired(Action callback) { calls.Add("remove-actions"); if (RemoveFailures-- > 0) throw new InvalidOperationException("actions remove"); if (ReferenceEquals(Callback, callback)) Callback = null; } public void SetCombatScope(InputScope? scope) => Scopes.Add(scope); public void Raise(InputAction action, ActivationType activation) => Callback?.Invoke(action, activation); } private sealed class FakeCombatSurface(List calls) : ICombatModeEventSurface { public CombatMode CurrentModeValue { get; set; } = CombatMode.NonCombat; public CombatMode CurrentMode => CurrentModeValue; public Action? Callback { get; private set; } public bool ThrowAfterAdd { get; set; } public int RemoveFailures { get; set; } public void AddChanged(Action callback) { calls.Add("add-combat"); Callback = callback; if (ThrowAfterAdd) throw new InvalidOperationException("combat add"); } public void RemoveChanged(Action callback) { calls.Add("remove-combat"); if (RemoveFailures-- > 0) throw new InvalidOperationException("combat remove"); if (ReferenceEquals(Callback, callback)) Callback = null; } public void Raise(CombatMode mode) => Callback?.Invoke(mode); } private sealed class FakeTargets(string? consumeAt = null) : IGameplayInputPriorityTargets { public List Calls { get; } = []; public bool HandlePointerAction(InputAction action, ActivationType activation) => Record("pointer"); public void HandleScroll(InputAction action) => Calls.Add($"scroll:{action}"); public bool HandleCombatAction(InputAction action, ActivationType activation) => Record("combat"); public bool HandleRetainedUiAction(InputAction action) => Record("retained"); public bool HandleSelectionAction(InputAction action) => Record("selection"); public bool HandlePressedMovementAction(InputAction action) => Record("movement"); public void HandleCommand(InputAction action) => Calls.Add("command"); private bool Record(string name) { Calls.Add(name); return consumeAt == name; } } }