acdream/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs
Erik 256e9624bd feat(input): #22 Phase K.1b - cut handlers over to dispatcher (single input path)
Removes the parallel direct keyboard/mouse polling that K.1a left in
GameWindow alongside the new dispatcher. Now every input flows
through InputDispatcher; legacy IsKeyPressed/KeyDown/MouseDown/MouseUp/
Scroll handlers in GameWindow are deleted (~220-line refactor).

Bindings remain acdream-current (W/S/A/D/Z/X movement, Shift run,
F-key debug surface). K.1c flips them to retail.

Pieces:
- InputDispatcher.IsActionHeld(InputAction): per-frame held-state
  query for movement (W/X/A/D/Z/X/Shift/Space) so PlayerMovement-
  Controller can read action state without polling raw keys.
  Internally walks all bindings for the action; chord match
  requires modifier mask exactness.
- InputAction adds AcdreamRmbOrbitHold (Hold-activation, RMB held
  drives chase-camera orbit) and AcdreamFlyDown (Ctrl held in fly
  mode for descent).
- GameWindow OnInputAction subscriber replaces the entire KeyDown
  switch + per-mouse-button handlers. Single dispatcher event drives:
    - F1  AcdreamToggleDebugPanel
    - F2  AcdreamToggleCollisionWires
    - F3  AcdreamDumpNearby
    - F7  AcdreamCycleTimeOfDay
    - F8  AcdreamSensitivityDown
    - F9  AcdreamSensitivityUp
    - F10 AcdreamCycleWeather
    - F   AcdreamToggleFlyMode
    - Tab AcdreamTogglePlayerMode (player/fly toggle - K.1c will
          reassign this to ToggleChatEntry)
    - Esc EscapeKey (cancel fly mode etc.)
    - Mouse wheel ScrollUp/ScrollDown (camera zoom)
    - RMB held (Hold) drives orbit; LMB drag still drives orbit
      camera; mouse position handled by surviving MouseMove handler
      which is gated on ImGui WantCaptureMouse.
- MovementInput per-frame: reads from _inputDispatcher.IsActionHeld.
  MouseDeltaX hardcoded to 0f (mouse never drives character yaw).
  _playerMouseDeltaX field stays defined for chase-camera RMB-orbit
  but is never consumed by movement.
- WantCaptureMouse explicit gate at the top of every surviving mouse
  handler in GameWindow (defense in depth - dispatcher already gates
  via IMouseSource.WantCaptureMouse).

Movement-input boundary preserved: PlayerMovementController.Update
still takes the same MovementInput struct. Existing
PlayerMovementControllerTests continue green - no regression in
motion-command byte production.

Two deviations:
1. Scroll lost magnitude going through the dispatcher (fixed-step
   zoom). Acceptable - discrete wheel-tick matches retail feel
   anyway.
2. Movement chords are duplicated with both ModifierMask.None and
   ModifierMask.Shift (covering "shift held to run while walking
   forward" etc.) so the dispatcher's modifier-strict matching
   preserves the modifier-blind feel of the old IsKeyPressed
   polling. Will be reshaped cleanly in K.1c when retail's
   walk-modifier semantics flip (default = run, shift held = walk).

15 new tests:
- InputDispatcherIsActionHeldTests: 7 cases covering chord-held +
  release + modifier-mismatch + multi-binding-for-action.
- InputDispatcherTests: 3 scroll-action cases.
- DispatcherToMovementIntegrationTests (Core.Tests): 5 cases
  proving FakeKeyboardSource.Press(W) -> dispatcher.IsActionHeld ->
  MovementInput.Forward -> PlayerMovementController produces the
  expected motion-command bytes. Includes the regression-prevention
  test that mouse-X delta value (zero vs nonzero) doesn't affect
  the motion bytes.

Solution total: 1133 green (243 Core.Net + 225 UI + 665 Core),
0 warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 23:43:11 +02:00

216 lines
6.9 KiB
C#

using System;
using System.Collections.Generic;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Tests.Input;
public class InputDispatcherTests
{
private static (InputDispatcher dispatcher, FakeKeyboardSource kb, FakeMouseSource mouse, KeyBindings bindings, List<(InputAction, ActivationType)> fired)
Build()
{
var kb = new FakeKeyboardSource();
var mouse = new FakeMouseSource();
var bindings = new KeyBindings();
var dispatcher = new InputDispatcher(kb, mouse, bindings);
var fired = new List<(InputAction, ActivationType)>();
dispatcher.Fired += (a, t) => fired.Add((a, t));
return (dispatcher, kb, mouse, bindings, fired);
}
[Fact]
public void KeyDown_with_matching_chord_fires_press_action()
{
var (_, kb, _, bindings, fired) = Build();
bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
kb.EmitKeyDown(Key.W, ModifierMask.None);
Assert.Single(fired);
Assert.Equal((InputAction.MovementForward, ActivationType.Press), fired[0]);
}
[Fact]
public void KeyDown_with_mismatched_modifier_does_not_fire()
{
var (_, kb, _, bindings, fired) = Build();
bindings.Add(new Binding(new KeyChord(Key.A, ModifierMask.Ctrl), InputAction.SelectionExamine));
// Press A without Ctrl — no fire.
kb.EmitKeyDown(Key.A, ModifierMask.None);
Assert.Empty(fired);
// Press A with Shift+Ctrl — also no fire (extra modifier).
kb.EmitKeyDown(Key.A, ModifierMask.Shift | ModifierMask.Ctrl);
Assert.Empty(fired);
// Exactly Ctrl+A fires.
kb.EmitKeyDown(Key.A, ModifierMask.Ctrl);
Assert.Single(fired);
}
[Fact]
public void WantCaptureKeyboard_suppresses_KeyDown_events()
{
var (_, kb, mouse, bindings, fired) = Build();
bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
mouse.WantCaptureKeyboard = true;
kb.EmitKeyDown(Key.W, ModifierMask.None);
Assert.Empty(fired);
}
[Fact]
public void WantCaptureMouse_suppresses_MouseDown_events()
{
var (_, _, mouse, bindings, fired) = Build();
var leftClick = new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Left), ModifierMask.None, Device: 1);
bindings.Add(new Binding(leftClick, InputAction.SelectLeft));
mouse.WantCaptureMouse = true;
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None);
Assert.Empty(fired);
}
[Fact]
public void Default_active_scope_is_Game()
{
var (dispatcher, _, _, _, _) = Build();
Assert.Equal(InputScope.Game, dispatcher.ActiveScope);
}
[Fact]
public void PushScope_changes_ActiveScope()
{
var (dispatcher, _, _, _, _) = Build();
dispatcher.PushScope(InputScope.Chat);
Assert.Equal(InputScope.Chat, dispatcher.ActiveScope);
}
[Fact]
public void PopScope_with_mismatched_expected_throws()
{
var (dispatcher, _, _, _, _) = Build();
dispatcher.PushScope(InputScope.Chat);
Assert.Throws<InvalidOperationException>(() => dispatcher.PopScope(InputScope.Dialog));
}
[Fact]
public void PopScope_restores_previous()
{
var (dispatcher, _, _, _, _) = Build();
dispatcher.PushScope(InputScope.Chat);
dispatcher.PopScope(InputScope.Chat);
Assert.Equal(InputScope.Game, dispatcher.ActiveScope);
}
[Fact]
public void Hold_binding_fires_Press_on_KeyDown_and_Release_on_KeyUp()
{
var (_, kb, _, bindings, fired) = Build();
bindings.Add(new Binding(
new KeyChord(Key.ShiftLeft, ModifierMask.None),
InputAction.MovementRunLock,
ActivationType.Hold));
kb.EmitKeyDown(Key.ShiftLeft, ModifierMask.None);
Assert.Contains((InputAction.MovementRunLock, ActivationType.Press), fired);
kb.EmitKeyUp(Key.ShiftLeft, ModifierMask.None);
Assert.Contains((InputAction.MovementRunLock, ActivationType.Release), fired);
}
[Fact]
public void Tick_fires_Hold_for_currently_held_chords()
{
var (dispatcher, kb, _, bindings, fired) = Build();
bindings.Add(new Binding(
new KeyChord(Key.ShiftLeft, ModifierMask.None),
InputAction.MovementRunLock,
ActivationType.Hold));
kb.EmitKeyDown(Key.ShiftLeft, ModifierMask.None);
fired.Clear(); // discard the initial Press transition
dispatcher.Tick();
dispatcher.Tick();
// Two Hold ticks while held.
Assert.Equal(2, fired.Count);
Assert.All(fired, e => Assert.Equal((InputAction.MovementRunLock, ActivationType.Hold), e));
kb.EmitKeyUp(Key.ShiftLeft, ModifierMask.None);
fired.Clear();
dispatcher.Tick();
Assert.Empty(fired); // no longer held
}
[Fact]
public void Release_binding_fires_only_on_KeyUp()
{
var (_, kb, _, bindings, fired) = Build();
bindings.Add(new Binding(
new KeyChord(Key.W, ModifierMask.None),
InputAction.MovementStop,
ActivationType.Release));
kb.EmitKeyDown(Key.W, ModifierMask.None);
Assert.Empty(fired);
kb.EmitKeyUp(Key.W, ModifierMask.None);
Assert.Single(fired);
Assert.Equal((InputAction.MovementStop, ActivationType.Release), fired[0]);
}
[Fact]
public void MouseDown_with_matching_chord_fires_action()
{
var (_, _, mouse, bindings, fired) = Build();
var leftClick = new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Left), ModifierMask.None, Device: 1);
bindings.Add(new Binding(leftClick, InputAction.SelectLeft));
mouse.EmitMouseDown(MouseButton.Left, ModifierMask.None);
Assert.Single(fired);
Assert.Equal((InputAction.SelectLeft, ActivationType.Press), fired[0]);
}
[Fact]
public void Tick_no_op_when_no_chords_held()
{
var (dispatcher, _, _, _, fired) = Build();
dispatcher.Tick();
Assert.Empty(fired);
}
[Fact]
public void Scroll_positive_emits_ScrollUp_press()
{
var (_, _, mouse, _, fired) = Build();
mouse.EmitScroll(1.0f);
Assert.Single(fired);
Assert.Equal((InputAction.ScrollUp, ActivationType.Press), fired[0]);
}
[Fact]
public void Scroll_negative_emits_ScrollDown_press()
{
var (_, _, mouse, _, fired) = Build();
mouse.EmitScroll(-1.0f);
Assert.Single(fired);
Assert.Equal((InputAction.ScrollDown, ActivationType.Press), fired[0]);
}
[Fact]
public void WantCaptureMouse_suppresses_Scroll_events()
{
var (_, _, mouse, _, fired) = Build();
mouse.WantCaptureMouse = true;
mouse.EmitScroll(1.0f);
Assert.Empty(fired);
}
}