acdream/tests/AcDream.UI.Abstractions.Tests/Input/InputDispatcherIsActionHeldTests.cs
Erik 785dd92378 fix(input): Phase K live-test fixes pt3 — fly→chase round-trip, Shift coexists, run-speed for backward + strafe
Four issues from the K-fix2 launch (2026-04-26 user report):

1. Can't return from free-fly to player view.
   CameraController.ToggleFly only swaps Fly↔Orbit, so a user who
   flew out of player mode landed in orbit (Holtburg) on
   toggle-back instead of the chase camera. Added
   ToggleFlyOrChase() helper that prefers Fly→Chase /
   Chase→Fly when _playerMode is true and a chase camera is
   available; falls back to the original Fly↔Orbit toggle for
   offline / pre-login flows. Wired into all three free-fly
   entry points: keyboard shortcut (Ctrl+Shift+F), Camera menu
   item, and DebugPanel button.

2. Shift while moving STOPS instead of dropping to walk.
   Root cause: InputDispatcher.IsChordHeld required
   _keyboard.CurrentModifiers to match chord.Modifiers EXACTLY.
   So with W bound as (W, None), holding W and then pressing
   Shift made CurrentModifiers=Shift mismatch chord (None) →
   IsActionHeld(MovementForward) returned false → Forward flag
   dropped → player stopped. Fixed by relaxing IsChordHeld:
   when chord.Modifiers is None, Shift is allowed to coexist
   (it's the retail walk-modifier). Other modifiers
   (Ctrl, Alt, Win) still mismatch strictly so Ctrl+W stays a
   distinct chord from W.

   +2 tests pinning the new permissive-Shift / strict-Ctrl
   semantics.

3. Backwards too slow when running.
   forwardCmdSpeed for the WalkBackward branch was hardcoded
   to 1.0; localY was hardcoded to -(WalkAnimSpeed * 0.65).
   Neither honored input.Run. With Run=true (default),
   backward now scales by runRate (~2.4×) so X = "run
   backwards" matches the forward run pace × the 0.65
   backward animation cycle ratio.

4. Strafe too slow when running.
   localX for SideStepLeft / SideStepRight was hardcoded to
   ±SidestepAnimSpeed regardless of Run. Same fix: when Run
   is held, scale by runRate so strafe at default speed
   matches the run-forward pace.

Tests: 1220 → 1222 (the two new IsChordHeld tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 14:48:45 +02:00

174 lines
7.3 KiB
C#

using System.Collections.Generic;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Tests.Input;
/// <summary>
/// K.1b: <see cref="InputDispatcher.IsActionHeld"/> per-frame held-key
/// polling. Movement (WASD/Shift/Space) needs "is W currently held this
/// frame" — that's IsActionHeld. The dispatcher's <c>Fired</c> events
/// drive press/release transitions; <c>IsActionHeld</c> drives the
/// per-frame <c>MovementInput</c> struct.
/// </summary>
public class InputDispatcherIsActionHeldTests
{
private static (InputDispatcher dispatcher, FakeKeyboardSource kb, FakeMouseSource mouse, KeyBindings bindings)
Build()
{
var kb = new FakeKeyboardSource();
var mouse = new FakeMouseSource();
var bindings = new KeyBindings();
var dispatcher = new InputDispatcher(kb, mouse, bindings);
return (dispatcher, kb, mouse, bindings);
}
[Fact]
public void IsActionHeld_returns_true_while_bound_key_held()
{
var (dispatcher, kb, _, bindings) = Build();
bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward));
kb.EmitKeyDown(Key.W, ModifierMask.None);
Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward));
kb.EmitKeyUp(Key.W, ModifierMask.None);
Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward));
}
[Fact]
public void IsActionHeld_returns_false_when_no_binding_for_action()
{
var (dispatcher, kb, _, bindings) = Build();
// No binding for MovementBackup at all.
bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
kb.EmitKeyDown(Key.W, ModifierMask.None);
Assert.False(dispatcher.IsActionHeld(InputAction.MovementBackup));
}
[Fact]
public void IsActionHeld_modifier_mismatch_returns_false()
{
var (dispatcher, kb, _, bindings) = Build();
bindings.Add(new Binding(new KeyChord(Key.A, ModifierMask.Ctrl), InputAction.SelectionExamine));
// A held without Ctrl — chord doesn't match.
kb.EmitKeyDown(Key.A, ModifierMask.None);
Assert.False(dispatcher.IsActionHeld(InputAction.SelectionExamine));
// Now release A and press Ctrl+A.
kb.EmitKeyUp(Key.A, ModifierMask.None);
kb.EmitKeyDown(Key.A, ModifierMask.Ctrl);
Assert.True(dispatcher.IsActionHeld(InputAction.SelectionExamine));
}
[Fact]
public void IsActionHeld_any_of_multiple_bindings_satisfies()
{
var (dispatcher, kb, _, bindings) = Build();
bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
bindings.Add(new Binding(new KeyChord(Key.Up, ModifierMask.None), InputAction.MovementForward));
kb.EmitKeyDown(Key.Up, ModifierMask.None);
Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward));
kb.EmitKeyUp(Key.Up, ModifierMask.None);
Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward));
kb.EmitKeyDown(Key.W, ModifierMask.None);
Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward));
}
[Fact]
public void IsActionHeld_works_for_mouse_button_chord()
{
var (dispatcher, _, mouse, bindings) = Build();
var rmb = new KeyChord(InputDispatcher.MouseButtonToKey(MouseButton.Right), ModifierMask.None, Device: 1);
bindings.Add(new Binding(rmb, InputAction.AcdreamRmbOrbitHold, ActivationType.Hold));
Assert.False(dispatcher.IsActionHeld(InputAction.AcdreamRmbOrbitHold));
mouse.EmitMouseDown(MouseButton.Right, ModifierMask.None);
Assert.True(dispatcher.IsActionHeld(InputAction.AcdreamRmbOrbitHold));
mouse.EmitMouseUp(MouseButton.Right, ModifierMask.None);
Assert.False(dispatcher.IsActionHeld(InputAction.AcdreamRmbOrbitHold));
}
[Fact]
public void IsActionHeld_returns_false_for_None_action()
{
var (dispatcher, _, _, _) = Build();
Assert.False(dispatcher.IsActionHeld(InputAction.None));
}
[Fact]
public void IsActionHeld_None_chord_remains_held_when_user_adds_Shift()
{
// K-fix3 (2026-04-26): Shift is the walk modifier in retail AC.
// The user holding W (default = run) and then pressing Shift
// should drop them to walk speed, NOT stop forward motion. Prior
// to this fix, IsChordHeld required CurrentModifiers to match
// chord.Modifiers EXACTLY — so (W, None) failed to match while
// CurrentModifiers=Shift, and the player stopped on Shift-press.
// Now: when chord requires no modifiers, Shift is allowed to
// coexist (other modifiers — Ctrl, Alt, Win — still mismatch).
var (dispatcher, kb, _, bindings) = Build();
bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
kb.EmitKeyDown(Key.W, ModifierMask.None);
Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward));
// User now holds Shift while still holding W. CurrentModifiers
// becomes Shift; W is still physically down.
kb.CurrentModifiers = ModifierMask.Shift;
Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward));
}
[Fact]
public void IsActionHeld_None_chord_does_not_fire_when_user_adds_Ctrl()
{
// Counterpart to the Shift test above: Ctrl is NOT a movement
// modifier, so Ctrl+W should be a different chord. Without an
// explicit (W, Ctrl) binding the action stays inactive — that's
// what makes Ctrl+F* / Ctrl+1-9 / etc. distinct from the bare
// F* / 1-9 chords.
var (dispatcher, kb, _, bindings) = Build();
bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
kb.EmitKeyDown(Key.W, ModifierMask.None);
Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward));
kb.CurrentModifiers = ModifierMask.Ctrl;
Assert.False(dispatcher.IsActionHeld(InputAction.MovementForward));
}
[Fact]
public void IsActionHeld_does_not_check_WantCaptureMouse()
{
// Per-frame held-state lookup is independent of UI capture: even
// with WantCaptureMouse=true a movement key already held when
// ImGui took focus continues to read as held until KeyUp. Press
// events ARE gated (the Press wouldn't fire while UI captures),
// but IsActionHeld answers the keyboard's underlying "is the
// physical key down right now" — which the legacy IsKeyPressed
// also did. The per-frame OnUpdate guard on
// ImGui.GetIO().WantCaptureKeyboard is what suppresses movement
// when chat is focused.
var (dispatcher, kb, mouse, bindings) = Build();
bindings.Add(new Binding(new KeyChord(Key.W, ModifierMask.None), InputAction.MovementForward));
kb.EmitKeyDown(Key.W, ModifierMask.None);
mouse.WantCaptureMouse = true;
mouse.WantCaptureKeyboard = true;
// Even with both capture flags set, IsActionHeld remains true
// because W is physically held. The dispatcher only suppresses
// press transitions.
Assert.True(dispatcher.IsActionHeld(InputAction.MovementForward));
}
}