acdream/tests/AcDream.UI.Abstractions.Tests/Input/KeyBindingsRetailTests.cs
Erik da189103b8 feat(input): #23 Phase K.1c - retail-faithful keymap cutover + JSON persistence (muscle memory change)
Single bisectable commit where the user-visible keyboard layout
flips from acdream-current (W/S/A/D/Z/X) to canonical AC retail
(W/X/A/D/Z/C). The InputDispatcher abstraction landed in K.1a,
existing handlers cut over in K.1b, and now KeyBindings.RetailDefaults()
returns the byte-precise retail preset matching
docs/research/named-retail/retail-default.keymap.txt.

Movement (matches AC1 muscle memory):
- W/Up = MovementForward (run by default)
- X/Down = MovementBackup
- A/Left = MovementTurnLeft
- D/Right = MovementTurnRight
- Z = MovementStrafeLeft
- C = MovementStrafeRight
- Alt+A / Alt+Left = MovementStrafeLeft (Alt-flips-turn)
- Alt+D / Alt+Right = MovementStrafeRight
- LShift (Hold) = MovementWalkMode (default = run; held = walk)
- Q = MovementRunLock (autorun toggle)
- S = MovementStop (sets Ready stance / idle)
- Space = MovementJump (hold to charge)
- Y = Ready, G = Sitting, H = Crouch, B = Sleeping (postures)

Selection / targeting (18 bindings on punctuation cluster):
- F = SelectionPickUp, T = SelectionSplitStack, P = PreviousSelection
- Backspace/Minus/Equals = closest/prev/next CompassItem
- Backslash/[/] = closest/prev/next Item
- Apostrophe/L/Semicolon = closest/prev/next Monster
- Home = LastAttacker
- Slash/Comma/Period = closest/prev/next Player
- N/M = prev/next Fellow
- E = SelectionExamine
- R = UseSelected

UI:
- F1 = ToggleHelp; Shift+Ctrl+F1 = TogglePluginManager
- F3 = Allegiance, F4 = Fellowship, F5 = Spellbook, F6 = SpellComponents
- F8 = Attributes, F9 = Skills, F10 = World, F11 = Options (lights up
  the Settings panel in K.3), F12 = Inventory
- Alt+1/2/3/4 = ToggleFloatingChatWindow1/2/3/4
- Esc = EscapeKey, Shift+Esc = LOGOUT
- Numpad * = CaptureScreenshot

Hotbar / spellbook:
- 1-9 = UseQuickSlot_1..9 (hotbar) AND UseSpellSlot_1..9 (in MagicCombat
  scope - dormant until Phase L)
- Ctrl+1-9 = UseQuickSlot_1..9 (duplicate)
- Alt+5-9 = UseQuickSlot_14..18 (second bar)
- 0 / Ctrl+0 = CreateShortcut

Chat:
- Tab = ToggleChatEntry (focus chat input; subscriber stub-TODO in K.2)
- Return = EnterChatMode (send)

Combat (mode-dependent, dormant - Phase L lights up):
- Grave (`) = CombatToggleCombat
- Insert/PgUp/Delete/End/PgDn = melee power+attack-level OR missile
  accuracy+aim-level OR magic spell-tab nav + cast (resolved by
  scope at runtime once CombatState.CurrentMode lands).
- Ctrl+Insert/PgUp/Delete/PgDn = first/last spell tab + first/last spell

Emotes: U = Cry, I = Laugh, J = Wave, O = Cheer, K = PointState

Camera (numpad cluster + F2):
- F2 / Numpad/ = CameraActivateAlternateMode
- Numpad 4/6/8/2 = rotate left/right/up/down
- Numpad - / + = move toward / away
- Numpad 0 = ViewDefault, Numpad . = FirstPerson
- Numpad 5 = LookDown, Numpad Enter = MapMode

Scroll:
- Mouse wheel handled by dispatcher OnScroll path
- Ctrl+Up / Ctrl+Down = ScrollUp / ScrollDown

Acdream debug actions relocated from F-keys to Ctrl+F-keys to avoid
retail conflicts:
- Ctrl+F1 = AcdreamToggleDebugPanel
- Ctrl+F2 = AcdreamToggleCollisionWires
- Ctrl+F3 = AcdreamDumpNearby
- Ctrl+F7 = AcdreamCycleTimeOfDay
- Ctrl+F8 / Ctrl+F9 = AcdreamSensitivityDown / Up
- Ctrl+F10 = AcdreamCycleWeather
AcdreamToggleFlyMode + AcdreamTogglePlayerMode have NO keyboard
binding in retail-default. K.2 adds a DebugPanel button for fly
toggle and auto-enter player mode at login.

Total: 149 bindings.

JSON load/save:
- KeyBindings.LoadOrDefault(path): merge-over-defaults migration.
  Missing actions get default bindings; unknown actions in user
  file are skipped (preserves user customizations across action
  enum additions). Corrupt file warns + returns RetailDefaults
  without overwriting (don't blow away user's file silently).
- KeyBindings.SaveToFile(path): writes with schema version=1, alpha-
  sorted action names, alpha-sorted modifier keys for stable diffs.
- KeyBindings.DefaultPath() = %LOCALAPPDATA%/acdream/keybinds.json.

GameWindow startup:
- Replaces KeyBindings.AcdreamCurrentDefaults() call with
  KeyBindings.LoadOrDefault(KeyBindings.DefaultPath()) via a small
  LoadStartupKeyBindings() helper.
- Logs "keybinds: loaded N bindings from <path>" so launch.log
  shows the source of truth at session start.

Three deviations from plan:
1. LoadStartupKeyBindings() helper instead of inline initializer
   (field initializer can't call methods directly).
2. ToggleChatEntry subscriber is a no-op stub with TODO K.2 comment
   (ChatPanel doesn't expose FocusInput() yet; will add in K.2).
3. AcdreamRmbOrbitHold removed from RetailDefaults() to avoid
   double-binding RMB (SelectRight + RmbOrbitHold on the same chord
   would fire both subscribers). Chase-camera orbit will be replaced
   by MMB-hold mouse-look in K.2 - retail's CameraInstantMouseLook.

28 new tests:
- KeyBindingsRetailTests: 19 cases pinning every retail mapping
  (W/X movement, Z/C strafe, Tab=ToggleChatEntry, Shift+Esc=LOGOUT,
  Shift+Ctrl+F1=TogglePluginManager, MovementWalkMode=Hold,
  Acdream debug on Ctrl+F*, hotbar number-row variants, etc).
- KeyBindingsJsonTests: 9 cases (round-trip; missing file →
  defaults; corrupt → defaults + no-overwrite; merge-over-defaults;
  legacy version=0 parsing; Hold-activation preservation; unknown-
  action skip; DefaultPath shape).

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

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

200 lines
7.4 KiB
C#

using System.Linq;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Tests.Input;
/// <summary>
/// K.1c: <see cref="KeyBindings.RetailDefaults"/> now returns the full
/// retail-faithful preset, byte-precise to
/// <c>docs/research/named-retail/retail-default.keymap.txt</c>. These
/// tests pin the canonical mappings the user-visible keyboard layout
/// shifts to (W/X movement instead of W/S, Z/C strafe instead of Z/X,
/// Tab → ToggleChatEntry instead of fly-mode toggle, etc).
/// </summary>
public class KeyBindingsRetailTests
{
[Fact]
public void MovementForward_bound_to_W_and_Up()
{
var b = KeyBindings.RetailDefaults();
var binds = b.ForAction(InputAction.MovementForward).ToList();
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.W, ModifierMask.None));
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Up, ModifierMask.None));
}
[Fact]
public void MovementBackup_bound_to_X_and_Down_NOT_S()
{
var b = KeyBindings.RetailDefaults();
var binds = b.ForAction(InputAction.MovementBackup).ToList();
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.X, ModifierMask.None));
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Down, ModifierMask.None));
// Retail: S is NOT MovementBackup. S → MovementStop.
Assert.DoesNotContain(binds, x => x.Chord.Key == Key.S);
}
[Fact]
public void S_key_is_MovementStop_in_retail()
{
var b = KeyBindings.RetailDefaults();
var stop = b.Find(new KeyChord(Key.S, ModifierMask.None), ActivationType.Press);
Assert.NotNull(stop);
Assert.Equal(InputAction.MovementStop, stop!.Value.Action);
}
[Fact]
public void MovementStrafeLeft_bound_to_Z_and_AltA_and_AltLeft()
{
var b = KeyBindings.RetailDefaults();
var binds = b.ForAction(InputAction.MovementStrafeLeft).ToList();
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Z, ModifierMask.None));
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.A, ModifierMask.Alt));
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Left, ModifierMask.Alt));
}
[Fact]
public void MovementStrafeRight_bound_to_C_and_AltD_and_AltRight()
{
var b = KeyBindings.RetailDefaults();
var binds = b.ForAction(InputAction.MovementStrafeRight).ToList();
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.C, ModifierMask.None));
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.D, ModifierMask.Alt));
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Right, ModifierMask.Alt));
}
[Fact]
public void Sleeping_bound_to_B()
{
var b = KeyBindings.RetailDefaults();
Assert.Contains(b.ForAction(InputAction.Sleeping),
x => x.Chord == new KeyChord(Key.B, ModifierMask.None));
}
[Fact]
public void MovementWalkMode_uses_Hold_activation()
{
var b = KeyBindings.RetailDefaults();
var binds = b.ForAction(InputAction.MovementWalkMode).ToList();
Assert.NotEmpty(binds);
Assert.All(binds, x => Assert.Equal(ActivationType.Hold, x.Activation));
Assert.Contains(binds, x => x.Chord.Key == Key.ShiftLeft);
}
[Fact]
public void ToggleChatEntry_bound_to_Tab_NOT_fly_toggle()
{
var b = KeyBindings.RetailDefaults();
var hit = b.Find(new KeyChord(Key.Tab, ModifierMask.None), ActivationType.Press);
Assert.NotNull(hit);
Assert.Equal(InputAction.ToggleChatEntry, hit!.Value.Action);
}
[Fact]
public void LOGOUT_is_Shift_Escape()
{
var b = KeyBindings.RetailDefaults();
var hit = b.Find(new KeyChord(Key.Escape, ModifierMask.Shift), ActivationType.Press);
Assert.NotNull(hit);
Assert.Equal(InputAction.LOGOUT, hit!.Value.Action);
}
[Fact]
public void EscapeKey_is_bare_Escape()
{
var b = KeyBindings.RetailDefaults();
var hit = b.Find(new KeyChord(Key.Escape, ModifierMask.None), ActivationType.Press);
Assert.NotNull(hit);
Assert.Equal(InputAction.EscapeKey, hit!.Value.Action);
}
[Fact]
public void UseQuickSlot_5_bound_to_Number5_with_and_without_Ctrl()
{
var b = KeyBindings.RetailDefaults();
var binds = b.ForAction(InputAction.UseQuickSlot_5).ToList();
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Number5, ModifierMask.None));
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Number5, ModifierMask.Ctrl));
}
[Fact]
public void UseQuickSlot_18_bound_to_Alt_Number9()
{
var b = KeyBindings.RetailDefaults();
var binds = b.ForAction(InputAction.UseQuickSlot_18).ToList();
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.Number9, ModifierMask.Alt));
}
[Fact]
public void TogglePluginManager_is_Shift_Ctrl_F1()
{
var b = KeyBindings.RetailDefaults();
var binds = b.ForAction(InputAction.TogglePluginManager).ToList();
Assert.Contains(binds, x =>
x.Chord.Key == Key.F1
&& x.Chord.Modifiers.HasFlag(ModifierMask.Shift)
&& x.Chord.Modifiers.HasFlag(ModifierMask.Ctrl));
}
[Fact]
public void Acdream_debug_actions_relocated_to_Ctrl_F_keys()
{
var b = KeyBindings.RetailDefaults();
Assert.Contains(b.ForAction(InputAction.AcdreamToggleDebugPanel),
x => x.Chord == new KeyChord(Key.F1, ModifierMask.Ctrl));
Assert.Contains(b.ForAction(InputAction.AcdreamToggleCollisionWires),
x => x.Chord == new KeyChord(Key.F2, ModifierMask.Ctrl));
Assert.Contains(b.ForAction(InputAction.AcdreamDumpNearby),
x => x.Chord == new KeyChord(Key.F3, ModifierMask.Ctrl));
}
[Fact]
public void ToggleHelp_is_bare_F1_NOT_acdream_debug()
{
var b = KeyBindings.RetailDefaults();
var hit = b.Find(new KeyChord(Key.F1, ModifierMask.None), ActivationType.Press);
Assert.NotNull(hit);
Assert.Equal(InputAction.ToggleHelp, hit!.Value.Action);
}
[Fact]
public void Total_binding_count_is_at_least_80()
{
var b = KeyBindings.RetailDefaults();
Assert.True(b.All.Count >= 80,
$"Expected at least 80 bindings, got {b.All.Count}");
}
[Fact]
public void Cry_emote_bound_to_U()
{
var b = KeyBindings.RetailDefaults();
Assert.Contains(b.ForAction(InputAction.Cry),
x => x.Chord == new KeyChord(Key.U, ModifierMask.None));
}
[Fact]
public void CombatToggleCombat_bound_to_GraveAccent()
{
var b = KeyBindings.RetailDefaults();
Assert.Contains(b.ForAction(InputAction.CombatToggleCombat),
x => x.Chord == new KeyChord(Key.GraveAccent, ModifierMask.None));
}
[Fact]
public void CameraActivateAlternateMode_bound_to_F2_and_NumpadDivide()
{
var b = KeyBindings.RetailDefaults();
var binds = b.ForAction(InputAction.CameraActivateAlternateMode).ToList();
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.F2, ModifierMask.None));
Assert.Contains(binds, x => x.Chord == new KeyChord(Key.KeypadDivide, ModifierMask.None));
}
[Fact]
public void ScrollUp_bound_to_Ctrl_Up()
{
var b = KeyBindings.RetailDefaults();
Assert.Contains(b.ForAction(InputAction.ScrollUp),
x => x.Chord == new KeyChord(Key.Up, ModifierMask.Ctrl));
}
}