acdream/tests/AcDream.UI.Abstractions.Tests/Input/MuteChordDispatchTests.cs
Erik dda76d9faf fix(input): #358 — RetailDefaults() never carried the Ctrl+M mute binding over from AcdreamCurrentDefaults()
Root cause, confirmed by a full-production-wiring repro test rather than
guessed: InputAction.AcdreamToggleAudioMute was bound to Ctrl+M only in
KeyBindings.AcdreamCurrentDefaults() -- the pre-K.1c WASD-only preset,
whose own doc comment says it is preserved solely as a regression anchor
and is explicitly NOT the GameWindow startup source after K.1c.
KeyBindings.RetailDefaults() -- what KeyBindings.LoadOrDefault actually
falls back to when no keybinds.json exists on disk (the verified state
on the affected machine) -- has its own "Acdream debug actions" block
(Ctrl+F1/F2/F3/F7/F8/F9/F10, Ctrl+Shift+F) but never carried the Ctrl+M
mute binding over into it. The live dispatcher therefore had no Ctrl+M
entry in its binding table at all -- not a modifier-matching bug, not a
scope bug, not a retained-UI-capture bug. Same class as the a5a7eb4f
jump fix (two construction paths, one wired to production), except here
it's two default-binding-set methods rather than two controller
instances, and the binding was simply added to the wrong one. This also
explains the prior "loaded 152 bindings both before and after" mystery:
the count correctly didn't change, because the earlier addition went
into a method nothing in production loads or counts.

MuteChordDispatchTests.CtrlM_WithNoWidgetFocused_FiresAcdreamToggleAudioMute
reproduces the full production shape (real RetailDefaults(), the
dispatcher's actual default [Always, Game] scope stack -- production
never calls PushScope/PopScope anywhere, grepped clean across
src/AcDream.App -- and a synthetic Ctrl+M keydown) and failed with an
EMPTY fired collection before this fix, which is what pinpointed
"missing table entry" over the other ranked hypotheses. A second test,
CtrlM_WhileAnyWidgetHoldsKeyboardFocus_IsSuppressed, pins a separate but
real mechanism found along the way (InputDispatcher.OnKeyDown returns
before FindActive when WantCaptureKeyboard is true, and production wires
that to "any focused widget", not just chat text entry) that was ruled
out as #358's cause since the baseline repro failed with nothing
focused.

Fix: KeyBindings.RetailDefaults() now also binds Ctrl+M to
AcdreamToggleAudioMute. Retail's own keymap has no Ctrl+M binding, so
this doesn't collide with anything retail-faithful. #358 closed in
ISSUES.md with the confirmed mechanism; connected verification (does
Ctrl+M actually mute in a live client) is still owed -- this session's
hard constraints excluded client launches.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:48:44 +02:00

100 lines
5.4 KiB
C#

using System.Collections.Generic;
using AcDream.UI.Abstractions.Input;
using Silk.NET.Input;
namespace AcDream.UI.Abstractions.Tests.Input;
/// <summary>
/// Issue #358 — Ctrl+M (<see cref="InputAction.AcdreamToggleAudioMute"/>)
/// never fired from a live session's <see cref="InputDispatcher"/>, despite
/// the downstream <c>GameplayInputCommandController</c> callback being
/// verified correct and the chord's modifier matching being verified
/// correct in isolation.
///
/// <para>
/// <b>Root cause, found by the first test below failing against the FULL
/// production wiring shape</b> (real <see cref="KeyBindings.RetailDefaults"/>,
/// not an ad-hoc test binding; the dispatcher's actual default scope stack —
/// production never calls <c>PushScope</c>/<c>PopScope</c> anywhere, grepped
/// clean across <c>src/AcDream.App</c>, so the stack is permanently
/// <see cref="InputScope.Always"/> + <see cref="InputScope.Game"/>, exactly
/// what the constructor pushes; and a synthetic Ctrl+M keydown): the Ctrl+M
/// binding existed ONLY in <see cref="KeyBindings.AcdreamCurrentDefaults"/>
/// (the pre-K.1c WASD-only preset, preserved solely as a regression anchor
/// for tests pinning that older layout — its own doc comment says outright
/// it is "NOT the GameWindow startup source after K.1c"). Production loads
/// bindings through <see cref="KeyBindings.LoadOrDefault"/>, which falls back
/// to <see cref="KeyBindings.RetailDefaults"/> when no <c>keybinds.json</c>
/// exists on disk (the verified state on this machine) — and
/// <see cref="KeyBindings.RetailDefaults"/> never carried the Ctrl+M binding
/// over. This is the same class of defect as the <c>a5a7eb4f</c> jump fix —
/// two construction paths for the same table shape, only one of them wired
/// to production — except here it is two DEFAULT-BINDING-SET methods rather
/// than two controller instances, and the binding was simply added to the
/// wrong one. Fixed by adding the chord to
/// <see cref="KeyBindings.RetailDefaults"/> alongside the other
/// <c>Acdream*</c> debug/utility actions relocated there to avoid retail
/// conflicts (KeyBindings.cs, the "Acdream debug actions" block) — retail's
/// own keymap (<c>docs/research/named-retail/retail-default.keymap.txt</c>)
/// has no Ctrl+M binding at all, so this doesn't collide with anything
/// retail-faithful.
/// </para>
/// </summary>
public sealed class MuteChordDispatchTests
{
private static (InputDispatcher dispatcher, FakeKeyboardSource kb, FakeMouseSource mouse, List<(InputAction Action, ActivationType Activation)> fired)
BuildWithRetailDefaults()
{
var kb = new FakeKeyboardSource();
var mouse = new FakeMouseSource(); // WantCaptureKeyboard defaults to false — no widget focused.
KeyBindings bindings = KeyBindings.RetailDefaults();
InputDispatcher dispatcher = InputDispatcher.CreateDetached(kb, mouse, bindings);
dispatcher.Attach(); // dispatcher constructor already pushes [Always, Game] — the
// production default; nothing in src/AcDream.App ever pushes
// another scope on top of it.
var fired = new List<(InputAction, ActivationType)>();
dispatcher.Fired += (a, t) => fired.Add((a, t));
return (dispatcher, kb, mouse, fired);
}
[Fact]
public void CtrlM_WithNoWidgetFocused_FiresAcdreamToggleAudioMute()
{
// The baseline production shape: KeyBindings.LoadOrDefault's fallback
// (RetailDefaults, matching the verified "no keybinds.json on disk"
// state), the dispatcher's real default [Always, Game] scope stack,
// nothing capturing the keyboard. This is the regression pin for
// #358's fix — before the fix, RetailDefaults carried no binding for
// AcdreamToggleAudioMute at all and this assertion failed with an
// EMPTY fired collection (not a wrong-action mismatch), proving the
// gap was a missing table entry, not a matching/scope/capture bug.
(_, FakeKeyboardSource kb, _, var fired) = BuildWithRetailDefaults();
kb.EmitKeyDown(Key.M, ModifierMask.Ctrl);
Assert.Contains((InputAction.AcdreamToggleAudioMute, ActivationType.Press), fired);
}
[Fact]
public void CtrlM_WhileAnyWidgetHoldsKeyboardFocus_IsSuppressed()
{
// Supporting evidence gathered during the #358 investigation, kept as
// a regression pin in its own right: InputDispatcher.OnKeyDown
// (src/AcDream.UI.Abstractions/Input/InputDispatcher.cs) returns
// BEFORE calling FindActive at all when _mouse.WantCaptureKeyboard is
// true. Production wires WantCaptureKeyboard to UiRoot.WantsKeyboard,
// which is "KeyboardFocus is not null" — ANY focused widget, not
// just a text-entry field specifically (the only AcceptsFocus=true
// widget type in the retained UI is UiField, so in practice this
// means any focused text field, e.g. chat entry or a Settings
// numeric box). This gate is real and total, but it was RULED OUT as
// #358's root cause: the baseline test above reproduced the bug with
// nothing focused at all.
(_, FakeKeyboardSource kb, FakeMouseSource mouse, var fired) = BuildWithRetailDefaults();
mouse.WantCaptureKeyboard = true;
kb.EmitKeyDown(Key.M, ModifierMask.Ctrl);
Assert.DoesNotContain((InputAction.AcdreamToggleAudioMute, ActivationType.Press), fired);
}
}