acdream/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherTests.cs
Erik 84512d3c64 feat(input): #21 Phase K.1a - input architecture skeleton (parallel to existing handlers)
Introduces the abstraction without changing user-visible behavior.
Existing keyboard/mouse handlers in GameWindow continue working
unchanged. The new InputDispatcher runs alongside, fires
InputAction events, and a diagnostic Console.WriteLine subscriber
proves the path is observable. K.1b cuts the existing handlers
over; K.1c flips bindings to retail.

New types in src/AcDream.UI.Abstractions/Input/:
- InputAction enum (~110 actions, doc-grouped by retail keymap
  category: MovementCommands, ItemSelectionCommands, UICommands,
  QuickslotCommands, Chat, Combat, Emotes, Camera, Scroll, Mouse
  selection, plus Acdream-specific debug actions for the existing
  F-key behaviors)
- KeyChord record struct (Silk.NET.Input.Key + ModifierMask + Device)
- ModifierMask [Flags] enum matching retail keymap bit values
  (Shift=0x01, Ctrl=0x02, Alt=0x04, Win=0x08)
- ActivationType enum (Press, Release, Hold, DoubleClick, Analog)
- Binding record (chord -> action -> activation)
- InputScope enum with stack semantics (Always at bottom, Game on
  top during normal play; Chat / EditField / Dialog / MeleeCombat /
  MissileCombat / MagicCombat / Camera push as transient overlays)
- KeyBindings collection class with Find / ForAction / Add / Remove.
  AcdreamCurrentDefaults() factory matches today's hardcoded binds
  (W/S/A/D/Z/X movement, Shift run, F-key debug surface) so K.1a
  doesn't change behavior. RetailDefaults() is K.1c's job; for now
  it returns the same map.
- IKeyboardSource / IMouseSource - test-fakeable interfaces wrapping
  Silk.NET. Both surface WantCaptureMouse / WantCaptureKeyboard
  flags so the dispatcher can gate per ImGui state.
- InputDispatcher: multicast event Fired<InputAction, ActivationType>;
  scope stack with PushScope/PopScope/ActiveScope; per-frame Tick()
  fires Hold-type bindings for currently-held chords; mouse buttons
  encoded as KeyChord with Device=1.

New adapters in src/AcDream.App/Input/:
- SilkKeyboardSource - Silk.NET IKeyboard wrapper, tracks held state
- SilkMouseSource - Silk.NET IMouse wrapper, proxies ImGui WantCapture
  flags for both keyboard and mouse

GameWindow.cs:
- Constructs adapters + dispatcher in OnLoad
- Subscribes to dispatcher.Fired with diagnostic Console.WriteLine
  ("[input] {action} {activation}") so the path is observable in
  launch.log without touching any actual game state
- Calls _inputDispatcher.Tick() per frame in OnUpdate
- Existing IsKeyPressed and event handlers unchanged

Memory crib at memory/project_input_pipeline.md describes the five
layers (Silk events -> Source interfaces -> Dispatcher -> Action
events -> Subscribers) with file paths + scope semantics + the K.1c
retail-defaults plan. Indexed in MEMORY.md.

Two deviations from plan, both documented:
1. InputDispatcher placed in UI.Abstractions/Input/ rather than
   App/Input/ - it has no Silk dependencies (uses only the test-
   fakeable interfaces) and the test fakes live in
   UI.Abstractions.Tests. Mirrors LiveCommandBus precedent. Silk
   adapters + GameWindow wiring stay in App.
2. WantCaptureKeyboard moved to IMouseSource alongside WantCaptureMouse
   (the dispatcher needs both at the same point).

34 new tests covering KeyChord equality, ModifierMask flags,
KeyBindings lookup, dispatcher chord matching with modifier
mismatch rejection, Hold-type Press/Release transitions, Tick()
firing held bindings, scope stack push/pop with mismatched-pop
throwing, WantCapture* gating.

Solution total: 1118 green (243 Core.Net + 215 UI + 660 Core),
0 warnings.

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

189 lines
6.1 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);
}
}